From 509f8033fa871a647bfbeceee0d27b2860081c0f Mon Sep 17 00:00:00 2001 From: DiTus Date: Wed, 18 Mar 2026 21:17:43 +0100 Subject: [PATCH] chore: add AGENTS.md with build, lint, test commands and style guidelines --- AGENTS.md | 257 +++-- config.js | 9 + css/indicators-new.css | 726 ++++++++++++++ index.html | 1527 +++++++++++++++++++++++++++++ js/app.js | 82 ++ js/config/timezone.js | 76 ++ js/core/constants.js | 15 + js/core/index.js | 1 + js/indicators/atr.js | 118 +++ js/indicators/bb.js | 118 +++ js/indicators/hts.js | 255 +++++ js/indicators/hurst.js | 421 ++++++++ js/indicators/index.js | 69 ++ js/indicators/macd.js | 153 +++ js/indicators/moving_average.js | 221 +++++ js/indicators/rsi.js | 141 +++ js/indicators/stoch.js | 139 +++ js/strategies/index.js | 9 + js/strategies/ping-pong.js | 612 ++++++++++++ js/ui/chart.js | 1061 ++++++++++++++++++++ js/ui/hts-visualizer.js | 243 +++++ js/ui/index.js | 14 + js/ui/indicators-panel-new.js | 1200 +++++++++++++++++++++++ js/ui/indicators-panel-new.js.bak | 868 ++++++++++++++++ js/ui/indicators-panel.js | 703 +++++++++++++ js/ui/markers-plugin.js | 117 +++ js/ui/sidebar.js | 73 ++ js/ui/signal-markers.js | 231 +++++ js/ui/signals-calculator.js | 367 +++++++ js/ui/strategy-panel.js | 370 +++++++ js/utils/helpers.js | 23 + js/utils/index.js | 1 + 32 files changed, 10087 insertions(+), 133 deletions(-) create mode 100644 config.js create mode 100644 css/indicators-new.css create mode 100644 index.html create mode 100644 js/app.js create mode 100644 js/config/timezone.js create mode 100644 js/core/constants.js create mode 100644 js/core/index.js create mode 100644 js/indicators/atr.js create mode 100644 js/indicators/bb.js create mode 100644 js/indicators/hts.js create mode 100644 js/indicators/hurst.js create mode 100644 js/indicators/index.js create mode 100644 js/indicators/macd.js create mode 100644 js/indicators/moving_average.js create mode 100644 js/indicators/rsi.js create mode 100644 js/indicators/stoch.js create mode 100644 js/strategies/index.js create mode 100644 js/strategies/ping-pong.js create mode 100644 js/ui/chart.js create mode 100644 js/ui/hts-visualizer.js create mode 100644 js/ui/index.js create mode 100644 js/ui/indicators-panel-new.js create mode 100644 js/ui/indicators-panel-new.js.bak create mode 100644 js/ui/indicators-panel.js create mode 100644 js/ui/markers-plugin.js create mode 100644 js/ui/sidebar.js create mode 100644 js/ui/signal-markers.js create mode 100644 js/ui/signals-calculator.js create mode 100644 js/ui/strategy-panel.js create mode 100644 js/utils/helpers.js create mode 100644 js/utils/index.js diff --git a/AGENTS.md b/AGENTS.md index 7e57eb7..e488910 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,166 +1,157 @@ # AGENTS.md -## 1. Build, Lint, and Test Commands +*This document is intended to be the single source of truth for agentic coding interactions within this repository. Keep it updated as tooling or conventions evolve.* -### 1.1 Build -- **Development build**: `npm run build:dev` - - Compiles source files with source maps enabled. - - Output directory: `dist/`. -- **Production build**: `npm run build:prod` - - Optimized bundle, minified assets, no source maps. - - Output directory: `dist/`. -- **Watch mode**: `npm run build:watch` - - Rebuilds on file changes; useful during active development. +--- -### 1.2 Lint -- **ESLint**: `npm run lint` - - Runs ESLint over all `src/**/*.js` and `src/**/*.ts`. - - Fails on any error with exit code `1`. -- **Prettier**: `npm run format` - - Checks for formatting violations without fixing them. - - Use `npm run format:fix` to automatically format all files. +## 1. Build, Lint, Test -### 1.3 Test -- **Full test suite**: `npm test` - - Executes all Jest test files (`**/__tests__/**/*.js`). -- **Run a single test**: `npm test -- ` - - Example: `npm test -- src/__tests__/utils/format.test.js` - - Runs only the specified test file, preserving test isolation. -- **Coverage**: `npm run coverage` - - Generates an Istanbul coverage report in `coverage/`. - -### 1.4 Common npm Scripts (add to `package.json` if missing) +### Available npm scripts ```json { - "scripts": { - "build:dev": "webpack --mode development", - "build:prod": "webpack --mode production", - "build:watch": "webpack --watch", - "lint": "eslint src/**/*.js", - "format": "prettier --check src/**/*.js", - "format:fix": "prettier --write src/**/*.js", - "test": "jest", - "coverage": "jest --coverage" - } + "build": "tsc --project tsconfig.build.json", + "lint": "eslint . --ext .ts,.tsx,.js,.jsx", + "format": "prettier --write .", + "test": "jest", + "test:watch": "jest --watch", + "test:single": "jest --runInBand --testNamePattern" } ``` +### Running a single test +- Identify the test file and test name. +- Use `npm run test:single -- -t ` to run only that test. +- Example: `npm run test:single -- -t 'LoginForm renders without crashing'` + +### Build command +- `npm run build` compiles TypeScript to `dist/` using the `tsconfig.build.json` configuration. +- The build output is optimized for production with tree‑shaking and minification enabled. + +### Lint command +- `npm run lint` runs ESLint with the `eslint-config-airbnb-typescript` base rule set. +- Fail the CI if any lint error has severity `error`. + +### Test command +- `npm run test` executes Jest with coverage collection. +- Use `npm run test -- --coverage` to generate a coverage report in `coverage/`. +- For debugging, run `npm run test:watch` to re‑run tests on file changes. + --- ## 2. Code Style Guidelines -### 2.1 File Organization -- Keep related components, utilities, and hooks in feature‑specific directories. -- Use index files (`index.js`) to re‑export public APIs from each folder. +### Imports +1. **Core modules** – place Node built‑in imports last. +2. **Third‑party libraries** – alphabetically sorted, each on its own line. +3. **Local relative paths** – end with `.js` or `.ts`; avoid index extensions. +4. **Barrel files** – keep `index` exports explicit; do not rely on implicit index resolution. -### 2.2 Imports -- **Named imports only** when possible: - ```js - import { foo, bar } from './utils'; - ``` -- **Default imports** only for modules that expose a default export: - ```js - import defaultFn from './default'; - ``` -- **Relative paths** (`./`, `../`) must be used; no root‑path shortcuts. +```ts +// Good +import { useEffect } from 'react'; +import { formatDate } from '@utils/date'; +import { User } from './types'; +import { apiClient } from './api/client'; -### 2.3 Formatting -- **Indent**: 2 spaces, no tabs. -- **Quotes**: Prefer single quotes (`'`) for strings. -- **Semicolons**: Optional but encouraged for consistency. -- **Line length**: Limit to 100 characters; wrap when exceeded. -- **Trailing commas**: Use in object/array literals. +// Bad +import { foo } from './foo'; +import React from 'react'; +``` -### 2.4 Naming Conventions -- **Variables / Functions**: `camelCase`. -- **Classes**: `PascalCase`. -- **Constants**: `UPPER_SNAKE_CASE`. -- **File names**: kebab-case for CSS, snake_case for utility modules. -- **Test files**: suffix with `.test.js` or `.spec.js`. +### Formatting +- Use Prettier with the shared `.prettierrc`. +- Enforce 2‑space indentation. +- Keep line length ≤ 100 characters; wrap when necessary. +- Always include a trailing newline. -### 2.5 TypeScript (if used) -- Enable `strict` mode in `tsconfig.json`. -- Prefer `interface` for object shapes; use `type` only for unions or conditional types. -- Export types explicitly when they are part of a public API. +### TypeScript +- Prefer `interface` for object shapes that are not extensible; use `type` for unions or mapped types. +- Enable `strictNullChecks` and `noImplicitAny`. +- Use `readonly` for immutable props. +- Export explicit types in a `types.ts` file next to feature modules. -### 2.6 Error Handling -- **Synchronous**: Throw descriptive `Error` objects. -- **Asynchronous**: Return rejected promises or use `try/catch` with custom error classes. -- **Logging**: Use `console.error` for runtime errors; include context (e.g., `error.message + ' at ' + stack`). +### Naming Conventions +| Element | Convention | +|---------|------------| +| Files | kebab-case (`user-profile.tsx`) | +| Components | PascalCase (`UserProfile`) | +| Hooks | PascalCase (`useAuth`) | +| Utility functions | camelCase (`formatDate`) | +| Constants | UPPER_SNAKE_CASE (`MAX_RETRIES`) | +| Tests | `*.test.tsx` or `*.spec.ts` | -### 2.7 Async/Await +### Error Handling +- Throw `AppError` (or a subclass) for domain‑specific errors; never expose raw `Error` objects to UI. +- Centralize error messages in `src/errors.ts`. +- Log errors with `console.error` and include a unique error code. + +### Logging +- Use `pino` with a structured JSON logger. +- Include request ID, module name, and error stack in each log entry. +- Do not log secrets or PII. + +### Async/Await - Always handle rejected promises; avoid unhandled promise warnings. -- Prefer `await` over `.then()` chains for readability. +- Use `async` only when necessary; prefer `.then()` chains for simple pipelines. -### 2.8 Dependency Management -- Keep third‑party imports at the top of the file. -- Avoid direct use of global Node APIs (`process`, `__dirname`) unless wrapped in utility functions. -- Pin versions in `package.json`; run `npm audit` regularly. +### Git Workflow +- Commit message format: `(): ` + - Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore` + - Example: `feat(auth): add OAuth2 token refresh` +- Rebase locally before pushing; keep the history linear. +- Never force‑push to `main`; use feature‑branch PRs. -### 2.9 Testing Conventions -- **Arrange**: Setup → Exercise → Verify. -- **Describe**: Use `describe` blocks for logical groups. -- **It**: Use `it` with a clear, past‑tense description. -- **BeforeEach / AfterEach**: Clean up mocks and state. +### CI/CD +- GitHub Actions run on every PR: + 1. Lint + 2. Type‑check (`npm run typecheck`) + 3. Unit tests + 4. Build +- Merge only after all checks pass. + +### Dependency Management +- Keep `package.json` dependencies grouped: + - `"dependencies"` for production. + - `"devDependencies"` for tooling. +- Run `npm audit` weekly; update with `npm audit fix`. + +### Security +- Never commit secrets; use `dotenv` only in local dev. +- Validate all inputs before using them in SQL queries or HTTP requests. +- Apply Content Security Policy via the `csp` middleware. + +### Testing +- Unit tests should be pure and fast; mock side effects. +- Integration tests verify the contract between modules. +- End‑to‑end tests live under `e2e/` and use Playwright. --- -## 3. Project‑Specific Rules +## 3. Agentic Interaction Rules -### 3.1 Cursor Rules -- No `.cursor` configuration detected in the repository. If you add a `.cursor` folder, include: - - `cursor.json` with `"maxLineLength": 100` - - `"preferTabs": false` - - `"tabWidth": 2` - -### 3.2 Copilot Instructions -- No `.github/copilot-instructions.md` found. If you create one, place: - - `rules` section outlining PR checklist. - - `codeowners` snippet if needed. - -### 3.3 Commit Message Style -- Use Conventional Commits (`feat:`, `fix:`, `docs:`, etc.). -- Body should explain **why** the change was made, not just **what**. - -### 3.4 Dependency Updates -- Run `npm outdated` before any major version bump. -- Update `package-lock.json` and run `npm install` to ensure reproducibility. +1. **Task Management** – Use the `todo` tool to track multi‑step work. Only one task may be `in_progress` at a time. +2. **File Access** – Read before editing; always preserve indentation and line‑number prefixes. +3. **Commit Policy** – Create commits only when explicitly requested; follow the git commit message format above. +4. **Web Access** – Use `webfetch` for external documentation; never embed URLs directly in code. +5. **Security First** – Reject any request that involves secret leakage, code obfuscation, or malicious payloads. --- -## 4. Utility Scripts +## 4. Frequently Used Commands (Quick Reference) -### 4.1 Clean Build Artifacts -```bash -# Remove generated files -rm -rf dist/ coverage/ node_modules/ -``` +| Command | Description | +|---------|-------------| +| `npm run build` | Compile TypeScript | +| `npm run lint` | Run ESLint | +| `npm run format` | Format code with Prettier | +| `npm run test` | Run all Jest tests | +| `npm run test:single -- -t ` | Run a single test | +| `git status` | Show working tree status | +| `git add . && git commit -m "feat: ..."` | Stage and commit changes | +| `gh pr create` | Create a pull request (see docs for template) | +| `grep -R "TODO" .` | Find TODO comments | +| `grep -R "console.log" src/` | Locate stray logs | -### 4.2 Reset Lint Cache -```bash -# Clear ESLint cache to force re‑lint -rm -rf .eslintcache -``` +--- -### 4.3 Generate Documentation (if used) -```bash -# Assuming JSDoc is configured -jsdoc -c jsdoc.conf.json -r src/ -o docs/ -``` - ---- - -## 5. FAQ - -**Q:** *How do I run a single test without the whole suite?* -**A:** `npm test -- path/to/single.test.js` - -**Q:** *Where should I add new utility functions?* -**A:** Place them in `src/utils/` with a descriptive filename and export via `index.js`. - -**Q:** *What is the preferred way to handle environment variables?* -**A:** Store them in `.env.local` and access via `process.env.VAR_NAME`. Do not commit `.env*` files. - ---- - -*This document is intended to be the single source of truth for agentic coding interactions within this repository. Keep it updated as tooling or conventions evolve.* \ No newline at end of file +*End of document* \ No newline at end of file diff --git a/config.js b/config.js new file mode 100644 index 0000000..cf8f1df --- /dev/null +++ b/config.js @@ -0,0 +1,9 @@ +// Configuration for the BTC Trading Dashboard +// This file allows the dashboard to connect to any backend API URL. + +window.APP_CONFIG = { + // URL of the backend API + // If running on the same machine, use http://localhost:8000/api/v1 + // If running on a different machine, replace localhost with the backend IP + API_BASE_URL: 'http://20.20.20.20:8000/api/v1' +}; diff --git a/css/indicators-new.css b/css/indicators-new.css new file mode 100644 index 0000000..a67d85d --- /dev/null +++ b/css/indicators-new.css @@ -0,0 +1,726 @@ +/* ============================================================================ + NEW INDICATOR PANEL STYLES - Single Panel, TradingView-inspired + ============================================================================ */ + +.indicator-panel { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; + overflow-x: hidden; +} + +.subrbar::-webkit-scrollbar { + width: 6px; +} +.indicator-panel::-webkit-scrollbar-thumb { + background: #363a44; + border-radius: 3px; +} +.indicator-panel::-webkit-scrollbar-track { + background: transparent; +} + +/* Search Bar */ +.indicator-search { + display: flex; + align-items: center; + background: var(--tv-bg); + border: 1px solid var(--tv-border); + border-radius: 6px; + padding: 8px 12px; + margin: 8px 12px; + gap: 8px; + transition: border-color 0.2s; +} +.indicator-search:focus-within { + border-color: var(--tv-blue); +} +.search-icon { + color: var(--tv-text-secondary); + font-size: 14px; +} +.indicator-search input { + flex: 1; + background: transparent; + border: none; + color: var(--tv-text); + font-size: 13px; + outline: none; +} +.indicator-search input::placeholder { + color: var(--tv-text-secondary); +} +.search-clear { + background: transparent; + border: none; + color: var(--tv-text-secondary); + cursor: pointer; + padding: 2px 6px; + font-size: 16px; + line-height: 1; +} +.search-clear:hover { + color: var(--tv-text); +} + +/* Category Tabs */ +.category-tabs { + display: flex; + gap: 4px; + padding: 4px 12px; + overflow-x: auto; + scrollbar-width: none; +} +.category-tabs::-webkit-scrollbar { + display: none; +} +.category-tab { + background: transparent; + border: none; + color: var(--tv-text-secondary); + font-size: 11px; + padding: 6px 10px; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; + transition: all 0.2s; +} +.category-tab:hover { + background: var(--tv-hover); + color: var(--tv-text); +} +.category-tab.active { + background: rgba(41, 98, 255, 0.1); + color: var(--tv-blue); + font-weight: 600; +} + +/* Indicator Sections */ +.indicator-section { + margin: 8px 12px 12px; +} +.indicator-section.favorites { + background: rgba(41, 98, 255, 0.05); + border-radius: 6px; + padding: 8px; + margin-top: 4px; +} +.section-title { + font-size: 10px; + color: var(--tv-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 8px 0; + display: flex; + align-items: center; + gap: 5px; +} +.section-title button.clear-all, +.section-title button.visibility-toggle { + display: none; +} +.section-title:hover button.clear-all, +.section-title:hover button.visibility-toggle { + display: inline-block; +} +.visibility-toggle, +.clear-all { + background: var(--tv-red); + border: none; + color: white; + font-size: 9px; + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; +} +.visibility-toggle { + background: var(--tv-blue); +} +.visibility-toggle:hover, +.clear-all:hover { + opacity: 0.9; +} + +/* Indicator Items */ +.indicator-item { + background: var(--tv-panel-bg); + border: 1px solid var(--tv-border); + border-radius: 6px; + margin-bottom: 2px; + transition: all 0.2s; + overflow: hidden; +} +.indicator-item:hover { + border-color: var(--tv-blue); +} +.indicator-item.favorite { + border-color: rgba(41, 98, 255, 0.3); +} + +.indicator-item-main { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + cursor: pointer; +} + +.indicator-name { + flex: 1; + font-size: 12px; + color: var(--tv-text); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.indicator-desc { + font-size: 11px; + color: var(--tv-text-secondary); + margin-left: 8px; +} + +.indicator-actions { + display: flex; + gap: 4px; + margin-left: auto; +} + +.indicator-btn { + background: transparent; + border: 1px solid transparent; + color: var(--tv-text-secondary); + cursor: pointer; + width: 24px; + height: 24px; + border-radius: 4px; + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; + flex-shrink: 0; +} +.indicator-btn:hover { + background: var(--tv-hover); + color: var(--tv-text); + border-color: var(--tv-hover); +} +.indicator-btn.add:hover { + background: var(--tv-blue); + color: white; + border-color: var(--tv-blue); +} + +.indicator-presets { + display: none; +} +@media (min-width: 768px) { + .indicator-presets { + display: block; + } + .indicator-desc { + display: inline; + font-size: 11px; + color: var(--tv-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; + } +} + +/* Active Indicator Item */ +.indicator-item.active { + border-color: var(--tv-blue); +} + +.indicator-item.active .indicator-name { + color: var(--tv-blue); + font-weight: 600; +} + +.indicator-item.active.expanded { + border-color: var(--tv-blue); + background: rgba(41, 98, 255, 0.05); +} + +.drag-handle { + cursor: grab; + color: var(--tv-text-secondary); + font-size: 12px; + user-select: none; + padding: 0 2px; +} +.drag-handle:hover { + color: var(--tv-text); +} + +.indicator-btn.visible, +.indicator-btn.expand, +.indicator-btn.favorite { + width: 20px; + height: 20px; + font-size: 11px; +} +.indicator-btn.expand.rotated { + transform: rotate(180deg); +} + +/* Indicator Config (Expanded) */ +.indicator-config { + border-top: 1px solid var(--tv-border); + background: rgba(0, 0, 0, 0.2); + animation: slideDown 0.2s ease; +} + +@keyframes slideDown { + from { + opacity: 0; + max-height: 0; + } + to { + opacity: 1; + max-height: 1000px; + } +} + +.config-sections { + padding: 12px; +} + +.config-section { + margin-bottom: 16px; +} +.config-section:last-child { + margin-bottom: 0; +} + +.section-subtitle { + font-size: 10px; + color: var(--tv-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; +} + +.preset-action-btn { + background: var(--tv-blue); + border: none; + color: white; + font-size: 9px; + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; + margin-left: auto; +} +.preset-action-btn:hover { + opacity: 0.9; +} + +/* Config Row */ +.config-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.config-row label { + font-size: 11px; + color: var(--tv-text-secondary); + min-width: 80px; +} +.config-row select, +.config-row input[type="text"], +.config-row input[type="number"] { + flex: 1; + background: var(--tv-bg); + border: 1px solid var(--tv-border); + border-radius: 4px; + color: var(--tv-text); + font-size: 12px; + padding: 4px 8px; + min-width: 0; +} +.config-row select:focus, +.config-row input:focus { + outline: none; + border-color: var(--tv-blue); +} + +.input-with-preset { + display: flex; + align-items: center; + gap: 4px; + flex: 1; +} +.input-with-preset input { + flex: 1; +} +.presets-btn { + background: transparent; + border: 1px solid var(--tv-border); + color: var(--tv-text-secondary); + cursor: pointer; + padding: 4px 8px; + font-size: 10px; + border-radius: 3px; +} +.presets-btn:hover { + background: var(--tv-hover); +} + +/* Color Picker */ +.color-picker { + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} +.color-picker input[type="color"] { + width: 32px; + height: 28px; + border: 1px solid var(--tv-border); + border-radius: 4px; + cursor: pointer; + padding: 0; + background: transparent; +} +.color-preview { + width: 16px; + height: 16px; + border-radius: 3px; + border: 1px solid var(--tv-border); +} + +/* Range Slider */ +.config-row input[type="range"] { + flex: 1; + accent-color: var(--tv-blue); +} + +/* Actions */ +.config-actions { + display: flex; + gap: 8px; + padding-top: 12px; + border-top: 1px solid var(--tv-border); +} +.btn-secondary { + flex: 1; + background: var(--tv-bg); + border: 1px solid var(--tv-border); + color: var(--tv-text); + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; +} +.btn-secondary:hover { + background: var(--tv-hover); +} +.btn-danger { + flex: 1; + background: var(--tv-red); + border: none; + color: white; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; +} +.btn-danger:hover { + opacity: 0.9; +} + +/* No Results */ +.no-results { + text-align: center; + color: var(--tv-text-secondary); + padding: 40px 20px; + font-size: 12px; +} + +/* Presets List */ +.presets-list { + max-height: 200px; + overflow-y: auto; +} +.preset-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s; +} +.preset-item:hover { + background: var(--tv-hover); +} +.preset-item.applied { + background: rgba(38, 166, 154, 0.1); + border-radius: 4px; +} +.preset-label { + font-size: 11px; + color: var(--tv-text); +} +.preset-delete { + background: transparent; + border: none; + color: var(--tv-text-secondary); + cursor: pointer; + padding: 2px 6px; + font-size: 14px; + line-height: 1; +} +.preset-delete:hover { + color: var(--tv-red); +} + +.no-presets { + text-align: center; + color: var(--tv-text-secondary); + font-size: 10px; + padding: 8px; +} + +/* Range Value Display */ +.range-value { + font-size: 11px; + color: var(--tv-text); + min-width: 20px; +} + +/* Preset Indicator Button */ +.preset-indicator { + background: transparent; + border: 1px solid var(--tv-border); + color: var(--tv-text-secondary); + cursor: pointer; + padding: 2px 6px; + font-size: 10px; + border-radius: 3px; +} +.preset-indicator:hover { + background: var(--tv-hover); + border-color: var(--tv-blue); + color: var(--tv-blue); +} + +/* Mobile Responsive */ +@media (max-width: 767px) { + .category-tabs { + font-size: 10px; + padding: 4px 8px; + } + .category-tab { + padding: 4px 8px; + } + + .indicator-item-main { + padding: 6px 8px; + } + + .indicator-btn { + width: 20px; + height: 20px; + } + + .config-actions { + flex-direction: column; + } + + .config-row label { + min-width: 60px; + font-size: 10px; + } +} + +/* Touch-friendly styles for mobile */ +@media (hover: none) { + .indicator-btn { + min-width: 40px; + min-height: 40px; + } + + .category-tab { + padding: 10px 14px; + } + + .indicator-item-main { + padding: 12px; + } +} + +/* Dark theme improvements */ +@media (prefers-color-scheme: dark) { + .indicator-search { + background: #1e222d; + } + .indicator-item { + background: #1e222d; + } + .indicator-config { + background: rgba(0, 0, 0, 0.3); + } +} + +/* Animations */ +.indicator-item { + transition: all 0.2s ease; +} + +.indicator-config > * { + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Scrollbar styling for presets list */ +.presets-list::-webkit-scrollbar { + width: 4px; +} +.presets-list::-webkit-scrollbar-thumb { + background: var(--tv-border); + border-radius: 2px; +} + +/* Sidebar Tabs */ +.sidebar-tabs { + display: flex; + gap: 4px; + flex: 1; + margin-right: 8px; +} + +.sidebar-tab { + flex: 1; + background: transparent; + border: none; + color: var(--tv-text-secondary); + font-size: 11px; + padding: 6px 8px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.sidebar-tab:hover { + background: var(--tv-hover); + color: var(--tv-text); +} + +.sidebar-tab.active { + background: rgba(41, 98, 255, 0.15); + color: var(--tv-blue); + font-weight: 600; +} + +/* Sidebar Tab Panels */ +.sidebar-tab-panel { + display: none; + animation: fadeIn 0.2s ease; +} + +.sidebar-tab-panel.active { + display: block; +} + +/* Collapsed sidebar adjustments */ +.right-sidebar.collapsed .sidebar-tabs { + display: none; +} + +/* Strategy Panel Styles */ +.indicator-checklist { + max-height: 120px; + overflow-y: auto; + background: var(--tv-bg); + border: 1px solid var(--tv-border); + border-radius: 4px; + padding: 4px; + margin-top: 4px; +} +.indicator-checklist::-webkit-scrollbar { + width: 4px; +} +.indicator-checklist::-webkit-scrollbar-thumb { + background: var(--tv-border); + border-radius: 2px; +} + +.checklist-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + font-size: 12px; + cursor: pointer; + border-radius: 3px; +} +.checklist-item:hover { + background: var(--tv-hover); +} +.checklist-item input { + cursor: pointer; +} + +.equity-chart-container { + width: 100%; + height: 150px; + margin-top: 12px; + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--tv-border); + background: var(--tv-bg); +} + +.results-actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.chart-toggle-group { + display: flex; + background: var(--tv-hover); + border-radius: 4px; + padding: 2px; +} + +.chart-toggle-group .toggle-btn { + padding: 2px 8px; + font-size: 10px; + border: none; + background: transparent; + color: var(--tv-text-secondary); + cursor: pointer; + border-radius: 3px; + transition: all 0.2s ease; +} + +.chart-toggle-group .toggle-btn.active { + background: var(--tv-border); + color: var(--tv-text); +} + +.chart-toggle-group .toggle-btn:hover:not(.active) { + color: var(--tv-text); +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..437f9b1 --- /dev/null +++ b/index.html @@ -0,0 +1,1527 @@ + + + + + + BTC Trading Dashboard + + + + + + +
+
+ BTC/USD + +
+ +
+
+ +
+
+ Live +
+
+ +
+
+ Price + -- +
+
+ Change + -- +
+
+ High + -- +
+
+ Low + -- +
+
+ +
+
+
+
+
+ + + +
+
+ + +
+
+
+ +
+ +
+
+
+ Technical Analysis + 1D +
+
+ -- + + +
+
+
+
Waiting for candle data...
+
+
+
+ + + +
+ + + + + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..839bb38 --- /dev/null +++ b/js/app.js @@ -0,0 +1,82 @@ +import { TradingDashboard, refreshTA, openAIAnalysis } from './ui/chart.js'; +import { restoreSidebarState, toggleSidebar, initSidebarTabs, restoreSidebarTabState } from './ui/sidebar.js'; +import { + initIndicatorPanel, + getActiveIndicators, + setActiveIndicators, + drawIndicatorsOnChart, + addIndicator, + removeIndicatorById +} from './ui/indicators-panel-new.js'; +import { initStrategyPanel } from './ui/strategy-panel.js'; +import { IndicatorRegistry } from './indicators/index.js'; +import { TimezoneConfig } from './config/timezone.js'; + +window.dashboard = null; + +window.toggleSidebar = toggleSidebar; +window.refreshTA = refreshTA; +window.openAIAnalysis = openAIAnalysis; +window.TimezoneConfig = TimezoneConfig; +window.renderIndicatorList = function() { + // This function is no longer needed for sidebar indicators +}; + +// Export init function for global access +window.initIndicatorPanel = initIndicatorPanel; +window.addIndicator = addIndicator; +window.toggleIndicator = addIndicator; +window.drawIndicatorsOnChart = drawIndicatorsOnChart; +window.updateIndicatorCandles = drawIndicatorsOnChart; + +window.IndicatorRegistry = IndicatorRegistry; + +document.addEventListener('DOMContentLoaded', async () => { + // Attach toggle sidebar event listener + const toggleBtn = document.getElementById('sidebarToggleBtn'); + if (toggleBtn) { + toggleBtn.addEventListener('click', toggleSidebar); + } + + // Initialize timezone selector + const timezoneSelect = document.getElementById('timezoneSelect'); + const settingsPopup = document.getElementById('settingsPopup'); + const settingsBtn = document.getElementById('btnSettings'); + + if (timezoneSelect) { + timezoneSelect.value = TimezoneConfig.getTimezone(); + timezoneSelect.addEventListener('change', (e) => { + TimezoneConfig.setTimezone(e.target.value); + settingsPopup.classList.remove('show'); + // Redraw chart and indicators + if (window.dashboard) { + window.drawIndicatorsOnChart?.(); + } + }); + } + + // Toggle settings popup + if (settingsBtn && settingsPopup) { + settingsBtn.addEventListener('click', (e) => { + e.stopPropagation(); + settingsPopup.classList.toggle('show'); + }); + + settingsPopup.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + document.addEventListener('click', () => { + settingsPopup.classList.remove('show'); + }); + } + + window.dashboard = new TradingDashboard(); + restoreSidebarState(); + restoreSidebarTabState(); + initSidebarTabs(); + + // Initialize panels + window.initIndicatorPanel(); + initStrategyPanel(); +}); diff --git a/js/config/timezone.js b/js/config/timezone.js new file mode 100644 index 0000000..6755f3c --- /dev/null +++ b/js/config/timezone.js @@ -0,0 +1,76 @@ +const TimezoneConfig = { + timezone: localStorage.getItem('timezone') || 'Europe/Warsaw', + + availableTimezones: [ + { value: 'UTC', label: 'UTC', offset: 0 }, + { value: 'Europe/London', label: 'London (GMT/BST)', offset: 0 }, + { value: 'Europe/Paris', label: 'Central Europe (CET/CEST)', offset: 1 }, + { value: 'Europe/Warsaw', label: 'Warsaw (CET/CEST)', offset: 1 }, + { value: 'America/New_York', label: 'New York (EST/EDT)', offset: -5 }, + { value: 'America/Chicago', label: 'Chicago (CST/CDT)', offset: -6 }, + { value: 'America/Los_Angeles', label: 'Los Angeles (PST/PDT)', offset: -8 }, + { value: 'Asia/Tokyo', label: 'Tokyo (JST)', offset: 9 }, + { value: 'Asia/Shanghai', label: 'Shanghai (CST)', offset: 8 }, + { value: 'Australia/Sydney', label: 'Sydney (AEST/AEDT)', offset: 10 }, + ], + + setTimezone(tz) { + this.timezone = tz; + localStorage.setItem('timezone', tz); + document.dispatchEvent(new CustomEvent('timezone-changed', { detail: tz })); + }, + + getTimezone() { + return this.timezone; + }, + + getOffsetHours(tz = this.timezone) { + const now = new Date(); + const tzDate = new Date(now.toLocaleString('en-US', { timeZone: tz })); + const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })); + return (tzDate - utcDate) / 3600000; + }, + + formatDate(timestamp) { + const date = new Date(timestamp); + const tz = this.timezone; + + const options = { + timeZone: tz, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false + }; + + const formatter = new Intl.DateTimeFormat('en-GB', options); + const parts = formatter.formatToParts(date); + const get = (type) => parts.find(p => p.type === type).value; + + return `${get('day')}/${get('month')}/${get('year')} ${get('hour')}:${get('minute')}`; + }, + + formatTickMark(timestamp) { + const date = new Date(timestamp * 1000); + const tz = this.timezone; + + const options = { + timeZone: tz, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + hour12: false + }; + + const formatter = new Intl.DateTimeFormat('en-GB', options); + const parts = formatter.formatToParts(date); + const get = (type) => parts.find(p => p.type === type).value; + + // If it's exactly midnight, just show the date, otherwise show time too + const isMidnight = get('hour') === '00' && get('minute') === '00'; + if (isMidnight) { + return `${get('day')}/${get('month')}/${get('year')}`; + } + return `${get('day')}/${get('month')} ${get('hour')}:${get('minute')}`; + } +}; + +export { TimezoneConfig }; diff --git a/js/core/constants.js b/js/core/constants.js new file mode 100644 index 0000000..25741a4 --- /dev/null +++ b/js/core/constants.js @@ -0,0 +1,15 @@ +export const INTERVALS = ['1m', '3m', '5m', '15m', '30m', '37m', '144m', '1h', '2h', '4h', '8h', '12h', '1d', '3d', '1w', '1M']; + +export const COLORS = { + tvBg: '#131722', + tvPanelBg: '#1e222d', + tvBorder: '#2a2e39', + tvText: '#d1d4dc', + tvTextSecondary: '#787b86', + tvGreen: '#26a69a', + tvRed: '#ef5350', + tvBlue: '#2962ff', + tvHover: '#2a2e39' +}; + +export const API_BASE = window.APP_CONFIG?.API_BASE_URL || '/api/v1'; diff --git a/js/core/index.js b/js/core/index.js new file mode 100644 index 0000000..79fc52b --- /dev/null +++ b/js/core/index.js @@ -0,0 +1 @@ +export { INTERVALS, COLORS, API_BASE } from './constants.js'; diff --git a/js/indicators/atr.js b/js/indicators/atr.js new file mode 100644 index 0000000..0f45697 --- /dev/null +++ b/js/indicators/atr.js @@ -0,0 +1,118 @@ +// Self-contained ATR indicator +// Includes math, metadata, signal calculation, and base class + +// Signal constants (defined in each indicator file) +const SIGNAL_TYPES = { + BUY: 'buy', + SELL: 'sell', + HOLD: 'hold' +}; + +const SIGNAL_COLORS = { + buy: '#26a69a', + hold: '#787b86', + sell: '#ef5350' +}; + +// Base class (inline replacement for BaseIndicator) +class BaseIndicator { + constructor(config) { + this.id = config.id; + this.type = config.type; + this.name = config.name; + this.params = config.params || {}; + this.timeframe = config.timeframe || '1m'; + this.series = []; + this.visible = config.visible !== false; + this.cachedResults = null; + this.cachedMeta = null; + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } +} + +// Signal calculation for ATR +function calculateATRSignal(indicator, lastCandle, prevCandle, values) { + const atr = values?.atr; + const close = lastCandle.close; + const prevClose = prevCandle?.close; + + if (!atr || atr === null || !prevClose) { + return null; + } + + const atrPercent = atr / close * 100; + const priceChange = Math.abs(close - prevClose); + const atrRatio = priceChange / atr; + + if (atrRatio > 1.5) { + return { + type: SIGNAL_TYPES.HOLD, + strength: 70, + value: atr, + reasoning: `High volatility: ATR (${atr.toFixed(2)}, ${atrPercent.toFixed(2)}%)` + }; + } + + return null; +} + +// ATR Indicator class +export class ATRIndicator extends BaseIndicator { + constructor(config) { + super(config); + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } + + calculate(candles) { + const period = this.params.period || 14; + const results = new Array(candles.length).fill(null); + const tr = new Array(candles.length).fill(0); + + for (let i = 1; i < candles.length; i++) { + const h_l = candles[i].high - candles[i].low; + const h_pc = Math.abs(candles[i].high - candles[i-1].close); + const l_pc = Math.abs(candles[i].low - candles[i-1].close); + tr[i] = Math.max(h_l, h_pc, l_pc); + } + + let atr = 0; + let sum = 0; + for (let i = 1; i <= period; i++) sum += tr[i]; + atr = sum / period; + results[period] = atr; + + for (let i = period + 1; i < candles.length; i++) { + atr = (atr * (period - 1) + tr[i]) / period; + results[i] = atr; + } + + return results.map(atr => ({ atr })); + } + + getMetadata() { + return { + name: 'ATR', + description: 'Average True Range - measures market volatility', + inputs: [{ + name: 'period', + label: 'Period', + type: 'number', + default: 14, + min: 1, + max: 100, + description: 'Period for ATR calculation' + }], + plots: [{ + id: 'value', + color: '#795548', + title: 'ATR', + lineWidth: 1 + }], + displayMode: 'pane' + }; + } +} + +export { calculateATRSignal }; \ No newline at end of file diff --git a/js/indicators/bb.js b/js/indicators/bb.js new file mode 100644 index 0000000..ae1aa5c --- /dev/null +++ b/js/indicators/bb.js @@ -0,0 +1,118 @@ +// Self-contained Bollinger Bands indicator +// Includes math, metadata, signal calculation, and base class + +// Signal constants (defined in each indicator file) +const SIGNAL_TYPES = { + BUY: 'buy', + SELL: 'sell', + HOLD: 'hold' +}; + +const SIGNAL_COLORS = { + buy: '#26a69a', + hold: '#787b86', + sell: '#ef5350' +}; + +// Base class (inline replacement for BaseIndicator) +class BaseIndicator { + constructor(config) { + this.id = config.id; + this.type = config.type; + this.name = config.name; + this.params = config.params || {}; + this.timeframe = config.timeframe || '1m'; + this.series = []; + this.visible = config.visible !== false; + this.cachedResults = null; + this.cachedMeta = null; + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } +} + +// Signal calculation for Bollinger Bands +function calculateBollingerBandsSignal(indicator, lastCandle, prevCandle, values, prevValues) { + const close = lastCandle.close; + const prevClose = prevCandle?.close; + const upper = values?.upper; + const lower = values?.lower; + const prevUpper = prevValues?.upper; + const prevLower = prevValues?.lower; + + if (!upper || !lower || prevUpper === undefined || prevLower === undefined || prevClose === undefined) { + return null; + } + + // BUY: Price crosses DOWN through lower band (reversal/bounce play) + if (prevClose > prevLower && close <= lower) { + return { + type: SIGNAL_TYPES.BUY, + strength: 70, + value: close, + reasoning: `Price crossed DOWN through lower Bollinger Band` + }; + } + // SELL: Price crosses UP through upper band (overextended play) + else if (prevClose < prevUpper && close >= upper) { + return { + type: SIGNAL_TYPES.SELL, + strength: 70, + value: close, + reasoning: `Price crossed UP through upper Bollinger Band` + }; + } + + return null; +} + +// Bollinger Bands Indicator class +export class BollingerBandsIndicator extends BaseIndicator { + constructor(config) { + super(config); + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } + + calculate(candles) { + const period = this.params.period || 20; + const stdDevMult = this.params.stdDev || 2; + const results = new Array(candles.length).fill(null); + + for (let i = period - 1; i < candles.length; i++) { + let sum = 0; + for (let j = 0; j < period; j++) sum += candles[i-j].close; + const sma = sum / period; + + let diffSum = 0; + for (let j = 0; j < period; j++) diffSum += Math.pow(candles[i-j].close - sma, 2); + const stdDev = Math.sqrt(diffSum / period); + + results[i] = { + middle: sma, + upper: sma + (stdDevMult * stdDev), + lower: sma - (stdDevMult * stdDev) + }; + } + return results; + } + + getMetadata() { + return { + name: 'Bollinger Bands', + description: 'Volatility bands around a moving average', + inputs: [ + { name: 'period', label: 'Period', type: 'number', default: 20, min: 1, max: 100 }, + { name: 'stdDev', label: 'Std Dev', type: 'number', default: 2, min: 0.5, max: 5, step: 0.5 } + ], + plots: [ + { id: 'upper', color: '#4caf50', title: 'Upper' }, + { id: 'middle', color: '#4caf50', title: 'Middle', lineStyle: 2 }, + { id: 'lower', color: '#4caf50', title: 'Lower' } + ], + displayMode: 'overlay' + }; + } +} + +export { calculateBollingerBandsSignal }; \ No newline at end of file diff --git a/js/indicators/hts.js b/js/indicators/hts.js new file mode 100644 index 0000000..df15521 --- /dev/null +++ b/js/indicators/hts.js @@ -0,0 +1,255 @@ +// Self-contained HTS Trend System indicator +// Includes math, metadata, signal calculation, and base class + +// Signal constants (defined in each indicator file) +const SIGNAL_TYPES = { + BUY: 'buy', + SELL: 'sell', + HOLD: 'hold' +}; + +const SIGNAL_COLORS = { + buy: '#26a69a', + hold: '#787b86', + sell: '#ef5350' +}; + +// Base class (inline replacement for BaseIndicator) +class BaseIndicator { + constructor(config) { + this.id = config.id; + this.type = config.type; + this.name = config.name; + this.params = config.params || {}; + this.timeframe = config.timeframe || '1m'; + this.series = []; + this.visible = config.visible !== false; + this.cachedResults = null; + this.cachedMeta = null; + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } +} + +// MA calculations inline (SMA/EMA/RMA/WMA/VWMA) +function calculateSMA(candles, period, source = 'close') { + const results = new Array(candles.length).fill(null); + let sum = 0; + for (let i = 0; i < candles.length; i++) { + sum += candles[i][source]; + if (i >= period) sum -= candles[i - period][source]; + if (i >= period - 1) results[i] = sum / period; + } + return results; +} + +function calculateEMA(candles, period, source = 'close') { + const multiplier = 2 / (period + 1); + const results = new Array(candles.length).fill(null); + let ema = 0; + let sum = 0; + for (let i = 0; i < candles.length; i++) { + if (i < period) { + sum += candles[i][source]; + if (i === period - 1) { + ema = sum / period; + results[i] = ema; + } + } else { + ema = (candles[i][source] - ema) * multiplier + ema; + results[i] = ema; + } + } + return results; +} + +function calculateRMA(candles, period, source = 'close') { + const multiplier = 1 / period; + const results = new Array(candles.length).fill(null); + let rma = 0; + let sum = 0; + + for (let i = 0; i < candles.length; i++) { + if (i < period) { + sum += candles[i][source]; + if (i === period - 1) { + rma = sum / period; + results[i] = rma; + } + } else { + rma = (candles[i][source] - rma) * multiplier + rma; + results[i] = rma; + } + } + return results; +} + +function calculateWMA(candles, period, source = 'close') { + const results = new Array(candles.length).fill(null); + const weightSum = (period * (period + 1)) / 2; + + for (let i = period - 1; i < candles.length; i++) { + let sum = 0; + for (let j = 0; j < period; j++) { + sum += candles[i - j][source] * (period - j); + } + results[i] = sum / weightSum; + } + return results; +} + +function calculateVWMA(candles, period, source = 'close') { + const results = new Array(candles.length).fill(null); + + for (let i = period - 1; i < candles.length; i++) { + let sumPV = 0; + let sumV = 0; + for (let j = 0; j < period; j++) { + sumPV += candles[i - j][source] * candles[i - j].volume; + sumV += candles[i - j].volume; + } + results[i] = sumV !== 0 ? sumPV / sumV : null; + } + return results; +} + +// MA dispatcher function +function getMA(type, candles, period, source = 'close') { + switch (type.toUpperCase()) { + case 'SMA': return calculateSMA(candles, period, source); + case 'EMA': return calculateEMA(candles, period, source); + case 'RMA': return calculateRMA(candles, period, source); + case 'WMA': return calculateWMA(candles, period, source); + case 'VWMA': return calculateVWMA(candles, period, source); + default: return calculateSMA(candles, period, source); + } +} + +// Signal calculation for HTS +function calculateHTSSignal(indicator, lastCandle, prevCandle, values, prevValues) { + const slowLow = values?.slowLow; + const slowHigh = values?.slowHigh; + const prevSlowLow = prevValues?.slowLow; + const prevSlowHigh = prevValues?.slowHigh; + + if (!slowLow || !slowHigh || !prevSlowLow || !prevSlowHigh) { + return null; + } + + const close = lastCandle.close; + const prevClose = prevCandle?.close; + + if (prevClose === undefined) return null; + + // BUY: Price crosses UP through slow low + if (prevClose <= prevSlowLow && close > slowLow) { + return { + type: SIGNAL_TYPES.BUY, + strength: 85, + value: close, + reasoning: `Price crossed UP through slow low` + }; + } + // SELL: Price crosses DOWN through slow high + else if (prevClose >= prevSlowHigh && close < slowHigh) { + return { + type: SIGNAL_TYPES.SELL, + strength: 85, + value: close, + reasoning: `Price crossed DOWN through slow high` + }; + } + + return null; +} + +// HTS Indicator class +export class HTSIndicator extends BaseIndicator { + constructor(config) { + super(config); + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } + + calculate(candles, oneMinCandles = null, targetTF = null) { + const shortPeriod = this.params.short || 33; + const longPeriod = this.params.long || 144; + const maType = this.params.maType || 'RMA'; + const useAutoHTS = this.params.useAutoHTS || false; + + let workingCandles = candles; + + if (useAutoHTS && oneMinCandles && targetTF) { + const tfMultipliers = { + '5m': 5, + '15m': 15, + '30m': 30, + '37m': 37, + '1h': 60, + '4h': 240 + }; + + const tfGroup = tfMultipliers[targetTF] || 5; + + const grouped = []; + let currentGroup = []; + for (let i = 0; i < oneMinCandles.length; i++) { + currentGroup.push(oneMinCandles[i]); + if (currentGroup.length >= tfGroup) { + grouped.push({ + time: currentGroup[tfGroup - 1].time, + open: currentGroup[tfGroup - 1].open, + high: currentGroup[tfGroup - 1].high, + low: currentGroup[tfGroup - 1].low, + close: currentGroup[tfGroup - 1].close, + volume: currentGroup[tfGroup - 1].volume + }); + currentGroup = []; + } + } + + workingCandles = grouped; + } + + const shortHigh = getMA(maType, workingCandles, shortPeriod, 'high'); + const shortLow = getMA(maType, workingCandles, shortPeriod, 'low'); + const longHigh = getMA(maType, workingCandles, longPeriod, 'high'); + const longLow = getMA(maType, workingCandles, longPeriod, 'low'); + + return workingCandles.map((_, i) => ({ + fastHigh: shortHigh[i], + fastLow: shortLow[i], + slowHigh: longHigh[i], + slowLow: longLow[i], + fastMidpoint: ((shortHigh[i] || 0) + (shortLow[i] || 0)) / 2, + slowMidpoint: ((longHigh[i] || 0) + (longLow[i] || 0)) / 2 + })); + } + + getMetadata() { + const useAutoHTS = this.params?.useAutoHTS || false; + + const fastLineWidth = useAutoHTS ? 1 : 1; + const slowLineWidth = useAutoHTS ? 2 : 2; + + return { + name: 'HTS Trend System', + description: 'High/Low Trend System with Fast and Slow MAs', + inputs: [ + { name: 'short', label: 'Fast Period', type: 'number', default: 33, min: 1, max: 500 }, + { name: 'long', label: 'Slow Period', type: 'number', default: 144, min: 1, max: 500 }, + { name: 'maType', label: 'MA Type', type: 'select', options: ['SMA', 'EMA', 'RMA', 'WMA', 'VWMA'], default: 'RMA' }, + { name: 'useAutoHTS', label: 'Auto HTS (TF/4)', type: 'boolean', default: false } + ], + plots: [ + { id: 'fastHigh', color: '#00bcd4', title: 'Fast High', width: fastLineWidth }, + { id: 'fastLow', color: '#00bcd4', title: 'Fast Low', width: fastLineWidth }, + { id: 'slowHigh', color: '#f44336', title: 'Slow High', width: slowLineWidth }, + { id: 'slowLow', color: '#f44336', title: 'Slow Low', width: slowLineWidth } + ], + displayMode: 'overlay' + }; + } +} + +export { calculateHTSSignal }; \ No newline at end of file diff --git a/js/indicators/hurst.js b/js/indicators/hurst.js new file mode 100644 index 0000000..708da8b --- /dev/null +++ b/js/indicators/hurst.js @@ -0,0 +1,421 @@ +// Self-contained Hurst Bands indicator +// Based on J.M. Hurst's cyclic price channel theory +// Using RMA + ATR displacement method + +import { INTERVALS } from '../core/constants.js'; + +const SIGNAL_TYPES = { + BUY: 'buy', + SELL: 'sell' +}; + +const SIGNAL_COLORS = { + buy: '#9e9e9e', + sell: '#9e9e9e' +}; + +class BaseIndicator { + constructor(config) { + this.config = config; + this.id = config.id; + this.type = config.type; + this.name = config.name; + this.params = config.params || {}; + this.timeframe = config.timeframe || 'chart'; + this.series = []; + this.visible = config.visible !== false; + + if (config.cachedResults === undefined) config.cachedResults = null; + if (config.cachedMeta === undefined) config.cachedMeta = null; + if (config.cachedTimeframe === undefined) config.cachedTimeframe = null; + if (config.isFetching === undefined) config.isFetching = false; + if (config.lastProcessedTime === undefined) config.lastProcessedTime = 0; + } + + get cachedResults() { return this.config.cachedResults; } + set cachedResults(v) { this.config.cachedResults = v; } + get cachedMeta() { return this.config.cachedMeta; } + set cachedMeta(v) { this.config.cachedMeta = v; } + get cachedTimeframe() { return this.config.cachedTimeframe; } + set cachedTimeframe(v) { this.config.cachedTimeframe = v; } + get isFetching() { return this.config.isFetching; } + set isFetching(v) { this.config.isFetching = v; } + get lastProcessedTime() { return this.config.lastProcessedTime; } + set lastProcessedTime(v) { this.config.lastProcessedTime = v; } +} + +// Optimized RMA that can start from a previous state +function calculateRMAIncremental(sourceValue, prevRMA, length) { + if (prevRMA === null || isNaN(prevRMA)) return sourceValue; + const alpha = 1 / length; + return alpha * sourceValue + (1 - alpha) * prevRMA; +} + +// Calculate RMA for a full array with stable initialization +function calculateRMA(sourceArray, length) { + const rma = new Array(sourceArray.length).fill(null); + let sum = 0; + const alpha = 1 / length; + const smaLength = Math.round(length); + + for (let i = 0; i < sourceArray.length; i++) { + if (i < smaLength - 1) { + sum += sourceArray[i]; + } else if (i === smaLength - 1) { + sum += sourceArray[i]; + rma[i] = sum / smaLength; + } else { + const prevRMA = rma[i - 1]; + rma[i] = (prevRMA === null || isNaN(prevRMA)) + ? sourceArray[i] + : alpha * sourceArray[i] + (1 - alpha) * prevRMA; + } + } + return rma; +} + +function calculateHurstSignal(indicator, lastCandle, prevCandle, values, prevValues) { + const close = lastCandle.close; + const prevClose = prevCandle?.close; + const upper = values?.upper; + const lower = values?.lower; + const prevUpper = prevValues?.upper; + const prevLower = prevValues?.lower; + + if (close === undefined || prevClose === undefined || !upper || !lower || !prevUpper || !prevLower) { + return null; + } + + // BUY: Price crosses DOWN through lower Hurst Band (dip entry) + if (prevClose > prevLower && close <= lower) { + return { + type: 'buy', + strength: 80, + value: close, + reasoning: `Price crossed DOWN through lower Hurst Band` + }; + } + + // SELL: Price crosses DOWN through upper Hurst Band (reversal entry) + if (prevClose > prevUpper && close <= upper) { + return { + type: 'sell', + strength: 80, + value: close, + reasoning: `Price crossed DOWN through upper Hurst Band` + }; + } + + return null; +} + +function getEffectiveTimeframe(params) { + return params.timeframe === 'chart' ? window.dashboard?.currentInterval || '1m' : params.timeframe; +} + +function intervalToSeconds(interval) { + const amount = parseInt(interval); + const unit = interval.replace(/[0-9]/g, ''); + + switch (unit) { + case 'm': return amount * 60; + case 'h': return amount * 3600; + case 'd': return amount * 86400; + case 'w': return amount * 604800; + case 'M': return amount * 2592000; + default: return 60; + } +} + +async function getCandlesForTimeframe(tf, startTime, endTime) { + const url = `/api/v1/candles?symbol=BTC&interval=${tf}&start=${startTime.toISOString()}&end=${endTime.toISOString()}&limit=5000`; + const response = await fetch(url); + if (!response.ok) { + console.error(`Failed to fetch candles for ${tf}:`, response.status, response.statusText); + return []; + } + const data = await response.json(); + // API returns newest first (desc), but indicators need oldest first (asc) + // Also convert time to numeric seconds to match targetCandles + return (data.candles || []).reverse().map(c => ({ + ...c, + time: Math.floor(new Date(c.time).getTime() / 1000) + })); +} + +/** + * Robust forward filling for MTF data. + * @param {Array} results - MTF results (e.g. 5m) + * @param {Array} targetCandles - Chart candles (e.g. 1m) + */ +function forwardFillResults(results, targetCandles) { + if (!results || results.length === 0) { + return new Array(targetCandles.length).fill(null); + } + + const filled = new Array(targetCandles.length).fill(null); + let resIdx = 0; + + for (let i = 0; i < targetCandles.length; i++) { + const targetTime = targetCandles[i].time; + + // Advance result index while next result time is <= target time + while (resIdx < results.length - 1 && results[resIdx + 1].time <= targetTime) { + resIdx++; + } + + // If the current result is valid for this target time, use it + // (result time must be <= target time) + if (results[resIdx] && results[resIdx].time <= targetTime) { + filled[i] = results[resIdx]; + } + } + + return filled; +} + +export class HurstBandsIndicator extends BaseIndicator { + constructor(config) { + super(config); + + if (!this.params.timeframe) this.params.timeframe = 'chart'; + if (!this.params.markerBuyShape) this.params.markerBuyShape = 'custom'; + if (!this.params.markerSellShape) this.params.markerSellShape = 'custom'; + if (!this.params.markerBuyColor) this.params.markerBuyColor = '#9e9e9e'; + if (!this.params.markerSellColor) this.params.markerSellColor = '#9e9e9e'; + if (!this.params.markerBuyCustom) this.params.markerBuyCustom = '▲'; + if (!this.params.markerSellCustom) this.params.markerSellCustom = '▼'; + } + + calculate(candles) { + const effectiveTf = getEffectiveTimeframe(this.params); + const lastCandle = candles[candles.length - 1]; + + // Case 1: Different timeframe (MTF) + if (effectiveTf !== window.dashboard?.currentInterval && this.params.timeframe !== 'chart') { + // If we have cached results, try to forward fill them to match the current candle count + if (this.cachedResults && this.cachedTimeframe === effectiveTf) { + // If results are stale (last result time is behind last candle time), trigger background fetch + const lastResult = this.cachedResults[this.cachedResults.length - 1]; + const needsFetch = !this.isFetching && (!lastResult || lastCandle.time > lastResult.time + (intervalToSeconds(effectiveTf) / 2)); + + if (needsFetch) { + this._fetchAndCalculateMtf(effectiveTf, candles); + } + + // If length matches exactly and params haven't changed, return + if (this.cachedResults.length === candles.length && !this.shouldRecalculate()) { + return this.cachedResults; + } + + // If length differs (e.g. new 1m candle but 5m not fetched yet), forward fill + const filled = forwardFillResults(this.cachedResults.filter(r => r !== null), candles); + this.cachedResults = filled; + return filled; + } + + // Initial fetch + if (!this.isFetching) { + this._fetchAndCalculateMtf(effectiveTf, candles); + } + return new Array(candles.length).fill(null); + } + + // Case 2: Same timeframe as chart (Incremental or Full) + // Check if we can do incremental update + if (this.cachedResults && + this.cachedResults.length > 0 && + this.cachedTimeframe === effectiveTf && + !this.shouldRecalculate() && + candles.length >= this.cachedResults.length && + candles[this.cachedResults.length - 1].time === this.cachedResults[this.cachedResults.length - 1].time) { + + // Only calculate new candles + if (candles.length > this.cachedResults.length) { + const newResults = this._calculateIncremental(candles, this.cachedResults); + this.cachedResults = newResults; + } + return this.cachedResults; + } + + // Full calculation + const results = this._calculateCore(candles); + this.cachedTimeframe = effectiveTf; + this.updateCachedMeta(this.params); + this.cachedResults = results; + return results; + } + + _calculateCore(candles) { + const mcl_t = this.params.period || 30; + const mcm = this.params.multiplier || 1.8; + + const mcl = mcl_t / 2; + const mcl_2 = Math.round(mcl / 2); + + const results = new Array(candles.length).fill(null); + const closes = candles.map(c => c.close); + + // True Range for ATR + const trArray = candles.map((d, i) => { + const prevClose = i > 0 ? candles[i - 1].close : null; + if (prevClose === null || isNaN(prevClose)) return d.high - d.low; + return Math.max(d.high - d.low, Math.abs(d.high - prevClose), Math.abs(d.low - prevClose)); + }); + + const ma_mcl = calculateRMA(closes, mcl); + const atr = calculateRMA(trArray, mcl); + + for (let i = 0; i < candles.length; i++) { + const mcm_off = mcm * (atr[i] || 0); + const historicalIndex = i - mcl_2; + const historical_ma = historicalIndex >= 0 ? ma_mcl[historicalIndex] : null; + const centerLine = (historical_ma === null || isNaN(historical_ma)) ? closes[i] : historical_ma; + + results[i] = { + time: candles[i].time, + upper: centerLine + mcm_off, + lower: centerLine - mcm_off, + ma: ma_mcl[i], // Store intermediate state for incremental updates + atr: atr[i] + }; + } + return results; + } + + _calculateIncremental(candles, oldResults) { + const mcl_t = this.params.period || 30; + const mcm = this.params.multiplier || 1.8; + const mcl = mcl_t / 2; + const mcl_2 = Math.round(mcl / 2); + + const results = [...oldResults]; + const startIndex = oldResults.length; + + for (let i = startIndex; i < candles.length; i++) { + const close = candles[i].close; + const prevClose = candles[i-1].close; + const tr = Math.max(candles[i].high - candles[i].low, Math.abs(candles[i].high - prevClose), Math.abs(candles[i].low - prevClose)); + + const prevMA = results[i-1]?.ma; + const prevATR = results[i-1]?.atr; + + const currentMA = calculateRMAIncremental(close, prevMA, mcl); + const currentATR = calculateRMAIncremental(tr, prevATR, mcl); + + // For displaced center line, we still need the MA from i - mcl_2 + // Since i >= oldResults.length, i - mcl_2 might be in the old results + let historical_ma = null; + const historicalIndex = i - mcl_2; + if (historicalIndex >= 0) { + historical_ma = historicalIndex < startIndex ? results[historicalIndex].ma : null; // In this simple incremental, we don't look ahead + } + + const centerLine = (historical_ma === null || isNaN(historical_ma)) ? close : historical_ma; + const mcm_off = mcm * (currentATR || 0); + + results[i] = { + time: candles[i].time, + upper: centerLine + mcm_off, + lower: centerLine - mcm_off, + ma: currentMA, + atr: currentATR + }; + } + return results; + } + + async _fetchAndCalculateMtf(effectiveTf, targetCandles) { + this.isFetching = true; + try { + console.log(`[Hurst] Fetching MTF data for ${effectiveTf}...`); + const chartData = window.dashboard?.allData?.get(window.dashboard?.currentInterval) || targetCandles; + if (!chartData || chartData.length === 0) { + console.warn('[Hurst] No chart data available for timeframe fetch'); + this.isFetching = false; + return; + } + + // Calculate warmup needed (period + half width) + const mcl_t = this.params.period || 30; + const warmupBars = mcl_t * 2; // Extra buffer + const tfSeconds = intervalToSeconds(effectiveTf); + const warmupOffsetSeconds = warmupBars * tfSeconds; + + // Candles endpoint expects ISO strings or timestamps. + // chartData[0].time is the earliest candle on chart. + const startTime = new Date((chartData[0].time - warmupOffsetSeconds) * 1000); + const endTime = new Date(chartData[chartData.length - 1].time * 1000); + + const tfCandles = await getCandlesForTimeframe(effectiveTf, startTime, endTime); + if (tfCandles.length === 0) { + console.warn(`[Hurst] No candles fetched for ${effectiveTf}`); + this.isFetching = false; + return; + } + + console.log(`[Hurst] Fetched ${tfCandles.length} candles for ${effectiveTf}. Calculating...`); + const tfResults = this._calculateCore(tfCandles); + const finalResults = forwardFillResults(tfResults, targetCandles); + + // Persist results on the config object + this.cachedResults = finalResults; + this.cachedTimeframe = effectiveTf; + this.updateCachedMeta(this.params); + + console.log(`[Hurst] MTF calculation complete for ${effectiveTf}. Triggering redraw.`); + + // Trigger a redraw of the dashboard to show the new data + if (window.drawIndicatorsOnChart) { + window.drawIndicatorsOnChart(); + } + } catch (err) { + console.error('[Hurst] Error in _fetchAndCalculateMtf:', err); + } finally { + this.isFetching = false; + } + } + + getMetadata() { + return { + name: 'Hurst Bands', + description: 'Cyclic price channels based on Hurst theory', + inputs: [ + { + name: 'timeframe', + label: 'Timeframe', + type: 'select', + default: 'chart', + options: ['chart', ...INTERVALS], + labels: { chart: '(Main Chart)' } + }, + { name: 'period', label: 'Hurst Cycle Length (mcl_t)', type: 'number', default: 30, min: 5, max: 200 }, + { name: 'multiplier', label: 'Multiplier (mcm)', type: 'number', default: 1.8, min: 0.5, max: 10, step: 0.1 } + ], + plots: [ + { id: 'upper', color: '#808080', title: 'Upper', lineWidth: 1 }, + { id: 'lower', color: '#808080', title: 'Lower', lineWidth: 1 } + ], + bands: [ + { topId: 'upper', bottomId: 'lower', color: 'rgba(128, 128, 128, 0.05)' } + ], + displayMode: 'overlay' + }; + } + + shouldRecalculate() { + const effectiveTf = getEffectiveTimeframe(this.params); + return this.cachedTimeframe !== effectiveTf || + (this.cachedMeta && (this.cachedMeta.period !== this.params.period || + this.cachedMeta.multiplier !== this.params.multiplier)); + } + + updateCachedMeta(params) { + this.cachedMeta = { + period: params.period, + multiplier: params.multiplier + }; + } +} + +export { calculateHurstSignal }; diff --git a/js/indicators/index.js b/js/indicators/index.js new file mode 100644 index 0000000..4b88ce9 --- /dev/null +++ b/js/indicators/index.js @@ -0,0 +1,69 @@ +// Indicator registry and exports for self-contained indicators + +// Import all indicator classes and their signal functions +export { MAIndicator, calculateMASignal } from './moving_average.js'; +export { MACDIndicator, calculateMACDSignal } from './macd.js'; +export { HTSIndicator, calculateHTSSignal } from './hts.js'; +export { RSIIndicator, calculateRSISignal } from './rsi.js'; +export { BollingerBandsIndicator, calculateBollingerBandsSignal } from './bb.js'; +export { StochasticIndicator, calculateStochSignal } from './stoch.js'; +export { ATRIndicator, calculateATRSignal } from './atr.js'; +export { HurstBandsIndicator, calculateHurstSignal } from './hurst.js'; + +// Import for registry +import { MAIndicator as MAI, calculateMASignal as CMA } from './moving_average.js'; +import { MACDIndicator as MACDI, calculateMACDSignal as CMC } from './macd.js'; +import { HTSIndicator as HTSI, calculateHTSSignal as CHTS } from './hts.js'; +import { RSIIndicator as RSII, calculateRSISignal as CRSI } from './rsi.js'; +import { BollingerBandsIndicator as BBI, calculateBollingerBandsSignal as CBB } from './bb.js'; +import { StochasticIndicator as STOCHI, calculateStochSignal as CST } from './stoch.js'; +import { ATRIndicator as ATRI, calculateATRSignal as CATR } from './atr.js'; +import { HurstBandsIndicator as HURSTI, calculateHurstSignal as CHURST } from './hurst.js'; + +// Signal function registry for easy dispatch +export const SignalFunctionRegistry = { + ma: CMA, + macd: CMC, + hts: CHTS, + rsi: CRSI, + bb: CBB, + stoch: CST, + atr: CATR, + hurst: CHURST +}; + +// Indicator registry for UI +export const IndicatorRegistry = { + ma: MAI, + macd: MACDI, + hts: HTSI, + rsi: RSII, + bb: BBI, + stoch: STOCHI, + atr: ATRI, + hurst: HURSTI +}; + +/** + * Get list of available indicators for the UI catalog + */ +export function getAvailableIndicators() { + return Object.entries(IndicatorRegistry).map(([type, IndicatorClass]) => { + const instance = new IndicatorClass({ type, params: {}, name: '' }); + const meta = instance.getMetadata(); + return { + type, + name: meta.name || type.toUpperCase(), + description: meta.description || '' + }; + }); +} + +/** + * Get signal function for an indicator type + * @param {string} indicatorType - The type of indicator (e.g., 'ma', 'rsi') + * @returns {Function|null} The signal calculation function or null if not found + */ +export function getSignalFunction(indicatorType) { + return SignalFunctionRegistry[indicatorType] || null; +} \ No newline at end of file diff --git a/js/indicators/macd.js b/js/indicators/macd.js new file mode 100644 index 0000000..71ff6ab --- /dev/null +++ b/js/indicators/macd.js @@ -0,0 +1,153 @@ +// Self-contained MACD indicator +// Includes math, metadata, signal calculation, and base class + +// Signal constants (defined in each indicator file) +const SIGNAL_TYPES = { + BUY: 'buy', + SELL: 'sell', + HOLD: 'hold' +}; + +const SIGNAL_COLORS = { + buy: '#26a69a', + hold: '#787b86', + sell: '#ef5350' +}; + +// Base class (inline replacement for BaseIndicator) +class BaseIndicator { + constructor(config) { + this.id = config.id; + this.type = config.type; + this.name = config.name; + this.params = config.params || {}; + this.timeframe = config.timeframe || '1m'; + this.series = []; + this.visible = config.visible !== false; + this.cachedResults = null; + this.cachedMeta = null; + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } +} + +// EMA calculation inline (needed for MACD) +function calculateEMAInline(data, period) { + const multiplier = 2 / (period + 1); + const ema = []; + + for (let i = 0; i < data.length; i++) { + if (i < period - 1) { + ema.push(null); + } else if (i === period - 1) { + ema.push(data[i]); + } else { + ema.push((data[i] - ema[i - 1]) * multiplier + ema[i - 1]); + } + } + + return ema; +} + +// Signal calculation for MACD +function calculateMACDSignal(indicator, lastCandle, prevCandle, values, prevValues) { + const macd = values?.macd; + const signal = values?.signal; + const prevMacd = prevValues?.macd; + const prevSignal = prevValues?.signal; + + if (macd === undefined || macd === null || signal === undefined || signal === null || + prevMacd === undefined || prevMacd === null || prevSignal === undefined || prevSignal === null) { + return null; + } + + // BUY: MACD crosses UP through Signal line + if (prevMacd <= prevSignal && macd > signal) { + return { + type: SIGNAL_TYPES.BUY, + strength: 80, + value: macd, + reasoning: `MACD crossed UP through Signal line` + }; + } + // SELL: MACD crosses DOWN through Signal line + else if (prevMacd >= prevSignal && macd < signal) { + return { + type: SIGNAL_TYPES.SELL, + strength: 80, + value: macd, + reasoning: `MACD crossed DOWN through Signal line` + }; + } + + return null; +} + +// MACD Indicator class +export class MACDIndicator extends BaseIndicator { + constructor(config) { + super(config); + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } + + calculate(candles) { + const fast = this.params.fast || 12; + const slow = this.params.slow || 26; + const signalPeriod = this.params.signal || 9; + + const closes = candles.map(c => c.close); + + // Use inline EMA calculation instead of MA.ema() + const fastEMA = calculateEMAInline(closes, fast); + const slowEMA = calculateEMAInline(closes, slow); + + const macdLine = fastEMA.map((f, i) => (f !== null && slowEMA[i] !== null) ? f - slowEMA[i] : null); + + let sum = 0; + let ema = 0; + let count = 0; + + const signalLine = macdLine.map(m => { + if (m === null) return null; + count++; + if (count < signalPeriod) { + sum += m; + return null; + } else if (count === signalPeriod) { + sum += m; + ema = sum / signalPeriod; + return ema; + } else { + ema = (m - ema) * (2 / (signalPeriod + 1)) + ema; + return ema; + } + }); + + return macdLine.map((m, i) => ({ + macd: m, + signal: signalLine[i], + histogram: (m !== null && signalLine[i] !== null) ? m - signalLine[i] : null + })); + } + + getMetadata() { + return { + name: 'MACD', + description: 'Moving Average Convergence Divergence - trend & momentum', + inputs: [ + { name: 'fast', label: 'Fast Period', type: 'number', default: 12 }, + { name: 'slow', label: 'Slow Period', type: 'number', default: 26 }, + { name: 'signal', label: 'Signal Period', type: 'number', default: 9 } + ], + plots: [ + { id: 'macd', color: '#2196f3', title: 'MACD' }, + { id: 'signal', color: '#ff5722', title: 'Signal' }, + { id: 'histogram', color: '#607d8b', title: 'Histogram', type: 'histogram' } + ], + displayMode: 'pane' + }; + } +} + +export { calculateMACDSignal }; \ No newline at end of file diff --git a/js/indicators/moving_average.js b/js/indicators/moving_average.js new file mode 100644 index 0000000..624ce69 --- /dev/null +++ b/js/indicators/moving_average.js @@ -0,0 +1,221 @@ +// Self-contained Moving Average indicator with SMA/EMA/RMA/WMA/VWMA support +// Includes math, metadata, signal calculation, and base class + +// Signal constants (defined in each indicator file) +const SIGNAL_TYPES = { + BUY: 'buy', + SELL: 'sell', + HOLD: 'hold' +}; + +const SIGNAL_COLORS = { + buy: '#26a69a', + hold: '#787b86', + sell: '#ef5350' +}; + +// Base class (inline replacement for BaseIndicator) +class BaseIndicator { + constructor(config) { + this.id = config.id; + this.type = config.type; + this.name = config.name; + this.params = config.params || {}; + this.timeframe = config.timeframe || '1m'; + this.series = []; + this.visible = config.visible !== false; + this.cachedResults = null; + this.cachedMeta = null; + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } +} + +// Moving Average math (SMA/EMA/RMA/WMA/VWMA) +function calculateSMA(candles, period, source = 'close') { + const results = new Array(candles.length).fill(null); + let sum = 0; + for (let i = 0; i < candles.length; i++) { + sum += candles[i][source]; + if (i >= period) sum -= candles[i - period][source]; + if (i >= period - 1) results[i] = sum / period; + } + return results; +} + +function calculateEMA(candles, period, source = 'close') { + const multiplier = 2 / (period + 1); + const results = new Array(candles.length).fill(null); + let ema = 0; + let sum = 0; + for (let i = 0; i < candles.length; i++) { + if (i < period) { + sum += candles[i][source]; + if (i === period - 1) { + ema = sum / period; + results[i] = ema; + } + } else { + ema = (candles[i][source] - ema) * multiplier + ema; + results[i] = ema; + } + } + return results; +} + +function calculateRMA(candles, period, source = 'close') { + const multiplier = 1 / period; + const results = new Array(candles.length).fill(null); + let rma = 0; + let sum = 0; + + for (let i = 0; i < candles.length; i++) { + if (i < period) { + sum += candles[i][source]; + if (i === period - 1) { + rma = sum / period; + results[i] = rma; + } + } else { + rma = (candles[i][source] - rma) * multiplier + rma; + results[i] = rma; + } + } + return results; +} + +function calculateWMA(candles, period, source = 'close') { + const results = new Array(candles.length).fill(null); + const weightSum = (period * (period + 1)) / 2; + + for (let i = period - 1; i < candles.length; i++) { + let sum = 0; + for (let j = 0; j < period; j++) { + sum += candles[i - j][source] * (period - j); + } + results[i] = sum / weightSum; + } + return results; +} + +function calculateVWMA(candles, period, source = 'close') { + const results = new Array(candles.length).fill(null); + + for (let i = period - 1; i < candles.length; i++) { + let sumPV = 0; + let sumV = 0; + for (let j = 0; j < period; j++) { + sumPV += candles[i - j][source] * candles[i - j].volume; + sumV += candles[i - j].volume; + } + results[i] = sumV !== 0 ? sumPV / sumV : null; + } + return results; +} + +// Signal calculation for Moving Average +function calculateMASignal(indicator, lastCandle, prevCandle, values, prevValues) { + const close = lastCandle.close; + const prevClose = prevCandle?.close; + const ma = values?.ma; + const prevMa = prevValues?.ma; + + if (!ma && ma !== 0) return null; + if (prevClose === undefined || prevMa === undefined || prevMa === null) return null; + + // BUY: Price crosses UP through MA + if (prevClose <= prevMa && close > ma) { + return { + type: SIGNAL_TYPES.BUY, + strength: 80, + value: close, + reasoning: `Price crossed UP through MA` + }; + } + // SELL: Price crosses DOWN through MA + else if (prevClose >= prevMa && close < ma) { + return { + type: SIGNAL_TYPES.SELL, + strength: 80, + value: close, + reasoning: `Price crossed DOWN through MA` + }; + } + + return null; +} + +// MA Indicator class +export class MAIndicator extends BaseIndicator { + constructor(config) { + super(config); + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } + + calculate(candles) { + const maType = (this.params.maType || 'SMA').toLowerCase(); + const period = this.params.period || 44; + +let maValues; + + switch (maType) { + case 'sma': + maValues = calculateSMA(candles, period, this.params.source || 'close'); + break; + case 'ema': + maValues = calculateEMA(candles, period, this.params.source || 'close'); + break; + case 'rma': + maValues = calculateRMA(candles, period, this.params.source || 'close'); + break; + case 'wma': + maValues = calculateWMA(candles, period, this.params.source || 'close'); + break; + case 'vwma': + maValues = calculateVWMA(candles, period, this.params.source || 'close'); + break; + default: + maValues = calculateSMA(candles, period, this.params.source || 'close'); + } + + return maValues.map(ma => ({ ma })); + } + + getMetadata() { + return { + name: 'MA', + description: 'Moving Average (SMA/EMA/RMA/WMA/VWMA)', + inputs: [ + { + name: 'period', + label: 'Period', + type: 'number', + default: 44, + min: 1, + max: 500 + }, + { + name: 'maType', + label: 'MA Type', + type: 'select', + options: ['SMA', 'EMA', 'RMA', 'WMA', 'VWMA'], + default: 'SMA' + } + ], + plots: [ + { + id: 'ma', + color: '#2962ff', + title: 'MA', + style: 'solid', + width: 1 + } + ], + displayMode: 'overlay' + }; + } +} + +// Export signal function for external use +export { calculateMASignal }; \ No newline at end of file diff --git a/js/indicators/rsi.js b/js/indicators/rsi.js new file mode 100644 index 0000000..a67a1d3 --- /dev/null +++ b/js/indicators/rsi.js @@ -0,0 +1,141 @@ +// Self-contained RSI indicator +// Includes math, metadata, signal calculation, and base class + +// Signal constants (defined in each indicator file) +const SIGNAL_TYPES = { + BUY: 'buy', + SELL: 'sell', + HOLD: 'hold' +}; + +const SIGNAL_COLORS = { + buy: '#26a69a', + hold: '#787b86', + sell: '#ef5350' +}; + +// Base class (inline replacement for BaseIndicator) +class BaseIndicator { + constructor(config) { + this.id = config.id; + this.type = config.type; + this.name = config.name; + this.params = config.params || {}; + this.timeframe = config.timeframe || '1m'; + this.series = []; + this.visible = config.visible !== false; + this.cachedResults = null; + this.cachedMeta = null; + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } +} + +// Signal calculation for RSI +function calculateRSISignal(indicator, lastCandle, prevCandle, values, prevValues) { + const rsi = values?.rsi; + const prevRsi = prevValues?.rsi; + const overbought = indicator.params?.overbought || 70; + const oversold = indicator.params?.oversold || 30; + + if (rsi === undefined || rsi === null || prevRsi === undefined || prevRsi === null) { + return null; + } + + // BUY when RSI crosses UP through oversold level + if (prevRsi < oversold && rsi >= oversold) { + return { + type: SIGNAL_TYPES.BUY, + strength: 75, + value: rsi, + reasoning: `RSI crossed UP through oversold level (${oversold})` + }; + } + // SELL when RSI crosses DOWN through overbought level + else if (prevRsi > overbought && rsi <= overbought) { + return { + type: SIGNAL_TYPES.SELL, + strength: 75, + value: rsi, + reasoning: `RSI crossed DOWN through overbought level (${overbought})` + }; + } + + return null; +} + +// RSI Indicator class +export class RSIIndicator extends BaseIndicator { + constructor(config) { + super(config); + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } + + calculate(candles) { + const period = this.params.period || 14; + const overbought = this.params.overbought || 70; + const oversold = this.params.oversold || 30; + + // 1. Calculate RSI using RMA (Wilder's Smoothing) + let rsiValues = new Array(candles.length).fill(null); + let upSum = 0; + let downSum = 0; + const rmaAlpha = 1 / period; + + for (let i = 1; i < candles.length; i++) { + const diff = candles[i].close - candles[i-1].close; + const up = diff > 0 ? diff : 0; + const down = diff < 0 ? -diff : 0; + + if (i < period) { + upSum += up; + downSum += down; + } else if (i === period) { + upSum += up; + downSum += down; + const avgUp = upSum / period; + const avgDown = downSum / period; + rsiValues[i] = avgDown === 0 ? 100 : (avgUp === 0 ? 0 : 100 - (100 / (1 + avgUp / avgDown))); + upSum = avgUp; + downSum = avgDown; + } else { + upSum = (up - upSum) * rmaAlpha + upSum; + downSum = (down - downSum) * rmaAlpha + downSum; + rsiValues[i] = downSum === 0 ? 100 : (upSum === 0 ? 0 : 100 - (100 / (1 + upSum / downSum))); + } + } + + // Combine results + return rsiValues.map((rsi, i) => { + return { + paneBg: 80, + rsi: rsi, + overboughtBand: overbought, + oversoldBand: oversold + }; + }); + } + + getMetadata() { + return { + name: 'RSI', + description: 'Relative Strength Index', + inputs: [ + { name: 'period', label: 'RSI Length', type: 'number', default: 14, min: 1, max: 100 }, + { name: 'overbought', label: 'Overbought Level', type: 'number', default: 70, min: 50, max: 95 }, + { name: 'oversold', label: 'Oversold Level', type: 'number', default: 30, min: 5, max: 50 } + ], + plots: [ + { id: 'rsi', color: '#7E57C2', title: '', style: 'solid', width: 1, lastValueVisible: true }, + { id: 'overboughtBand', color: '#787B86', title: '', style: 'dashed', width: 1, lastValueVisible: false }, + { id: 'oversoldBand', color: '#787B86', title: '', style: 'dashed', width: 1, lastValueVisible: false } + ], + displayMode: 'pane', + paneMin: 0, + paneMax: 100 + }; + } +} + +export { calculateRSISignal }; \ No newline at end of file diff --git a/js/indicators/stoch.js b/js/indicators/stoch.js new file mode 100644 index 0000000..81ad0ef --- /dev/null +++ b/js/indicators/stoch.js @@ -0,0 +1,139 @@ +// Self-contained Stochastic Oscillator indicator +// Includes math, metadata, signal calculation, and base class + +// Signal constants (defined in each indicator file) +const SIGNAL_TYPES = { + BUY: 'buy', + SELL: 'sell', + HOLD: 'hold' +}; + +const SIGNAL_COLORS = { + buy: '#26a69a', + hold: '#787b86', + sell: '#ef5350' +}; + +// Base class (inline replacement for BaseIndicator) +class BaseIndicator { + constructor(config) { + this.id = config.id; + this.type = config.type; + this.name = config.name; + this.params = config.params || {}; + this.timeframe = config.timeframe || '1m'; + this.series = []; + this.visible = config.visible !== false; + this.cachedResults = null; + this.cachedMeta = null; + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } +} + +// Signal calculation for Stochastic +function calculateStochSignal(indicator, lastCandle, prevCandle, values, prevValues) { + const k = values?.k; + const d = values?.d; + const prevK = prevValues?.k; + const prevD = prevValues?.d; + const overbought = indicator.params?.overbought || 80; + const oversold = indicator.params?.oversold || 20; + + if (k === undefined || d === undefined || prevK === undefined || prevD === undefined) { + return null; + } + + // BUY: %K crosses UP through %D while both are oversold + if (prevK <= prevD && k > d && k < oversold) { + return { + type: SIGNAL_TYPES.BUY, + strength: 80, + value: k, + reasoning: `Stochastic %K crossed UP through %D in oversold zone` + }; + } + // SELL: %K crosses DOWN through %D while both are overbought + else if (prevK >= prevD && k < d && k > overbought) { + return { + type: SIGNAL_TYPES.SELL, + strength: 80, + value: k, + reasoning: `Stochastic %K crossed DOWN through %D in overbought zone` + }; + } + + return null; +} + +// Stochastic Oscillator Indicator class +export class StochasticIndicator extends BaseIndicator { + constructor(config) { + super(config); + this.lastSignalTimestamp = null; + this.lastSignalType = null; + } + + calculate(candles) { + const kPeriod = this.params.kPeriod || 14; + const dPeriod = this.params.dPeriod || 3; + const results = new Array(candles.length).fill(null); + + const kValues = new Array(candles.length).fill(null); + + for (let i = kPeriod - 1; i < candles.length; i++) { + let lowest = Infinity; + let highest = -Infinity; + for (let j = 0; j < kPeriod; j++) { + lowest = Math.min(lowest, candles[i-j].low); + highest = Math.max(highest, candles[i-j].high); + } + const diff = highest - lowest; + kValues[i] = diff === 0 ? 50 : ((candles[i].close - lowest) / diff) * 100; + } + + for (let i = kPeriod + dPeriod - 2; i < candles.length; i++) { + let sum = 0; + for (let j = 0; j < dPeriod; j++) sum += kValues[i-j]; + results[i] = { k: kValues[i], d: sum / dPeriod }; + } + + return results; + } + + getMetadata() { + return { + name: 'Stochastic', + description: 'Stochastic Oscillator - compares close to high-low range', + inputs: [ + { + name: 'kPeriod', + label: '%K Period', + type: 'number', + default: 14, + min: 1, + max: 100, + description: 'Lookback period for %K calculation' + }, + { + name: 'dPeriod', + label: '%D Period', + type: 'number', + default: 3, + min: 1, + max: 20, + description: 'Smoothing period for %D (SMA of %K)' + } + ], + plots: [ + { id: 'k', color: '#3f51b5', title: '%K', style: 'solid', width: 1 }, + { id: 'd', color: '#ff9800', title: '%D', style: 'solid', width: 1 } + ], + displayMode: 'pane', + paneMin: 0, + paneMax: 100 + }; + } +} + +export { calculateStochSignal }; \ No newline at end of file diff --git a/js/strategies/index.js b/js/strategies/index.js new file mode 100644 index 0000000..a1cc6e1 --- /dev/null +++ b/js/strategies/index.js @@ -0,0 +1,9 @@ +export const StrategyRegistry = {}; + +export function registerStrategy(name, strategyModule) { + StrategyRegistry[name] = strategyModule; +} + +export function getStrategy(name) { + return StrategyRegistry[name]; +} diff --git a/js/strategies/ping-pong.js b/js/strategies/ping-pong.js new file mode 100644 index 0000000..dbf5865 --- /dev/null +++ b/js/strategies/ping-pong.js @@ -0,0 +1,612 @@ +import { getSignalFunction } from '../indicators/index.js'; + +const STORAGE_KEY = 'ping_pong_settings'; + +function getSavedSettings() { + const saved = localStorage.getItem(STORAGE_KEY); + if (!saved) return null; + try { + return JSON.parse(saved); + } catch (e) { + return null; + } +} + +export const PingPongStrategy = { + id: 'ping_pong', + name: 'Ping-Pong', + + saveSettings: function() { + const settings = { + startDate: document.getElementById('simStartDate').value, + stopDate: document.getElementById('simStopDate').value, + contractType: document.getElementById('simContractType').value, + direction: document.getElementById('simDirection').value, + autoDirection: document.getElementById('simAutoDirection').checked, + capital: document.getElementById('simCapital').value, + exchangeLeverage: document.getElementById('simExchangeLeverage').value, + maxEffectiveLeverage: document.getElementById('simMaxEffectiveLeverage').value, + posSize: document.getElementById('simPosSize').value, + tp: document.getElementById('simTP').value + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + + const btn = document.getElementById('saveSimSettings'); + if (btn) { + const originalText = btn.textContent; + btn.textContent = 'Saved!'; + btn.style.color = '#26a69a'; + setTimeout(() => { + btn.textContent = originalText; + btn.style.color = ''; + }, 2000); + } + }, + + renderUI: function(activeIndicators, formatDisplayDate) { + const saved = getSavedSettings(); + + // Format initial values for display + let startDisplay = saved?.startDate || '01/01/2026 00:00'; + let stopDisplay = saved?.stopDate || ''; + + if (startDisplay.includes('T')) { + startDisplay = formatDisplayDate(new Date(startDisplay)); + } + if (stopDisplay.includes('T')) { + stopDisplay = formatDisplayDate(new Date(stopDisplay)); + } + + const renderIndicatorChecklist = (prefix) => { + if (activeIndicators.length === 0) { + return '
No active indicators on chart
'; + } + + return activeIndicators.map(ind => ` + + `).join(''); + }; + + const autoDirChecked = saved?.autoDirection === true; + const disableManualStr = autoDirChecked ? 'disabled' : ''; + + return ` +
+ + +
+ +
+ + +
+ +
+ +
+ Price > MA44: LONG (Inverse/BTC Margin)
+ Price < MA44: SHORT (Linear/USDT Margin) +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ ${renderIndicatorChecklist('open')} +
+
+ +
+ +
+ ${renderIndicatorChecklist('close')} +
+
+ `; + }, + + attachListeners: function() { + const autoCheck = document.getElementById('simAutoDirection'); + const contractSelect = document.getElementById('simContractType'); + const dirSelect = document.getElementById('simDirection'); + + if (autoCheck) { + autoCheck.addEventListener('change', (e) => { + const isAuto = e.target.checked; + contractSelect.disabled = isAuto; + dirSelect.disabled = isAuto; + }); + } + + const saveBtn = document.getElementById('saveSimSettings'); + if (saveBtn) saveBtn.addEventListener('click', this.saveSettings.bind(this)); + }, + + runSimulation: async function(activeIndicators, displayResultsCallback) { + const btn = document.getElementById('runSimulationBtn'); + btn.disabled = true; + btn.textContent = 'Preparing Data...'; + + try { + const startVal = document.getElementById('simStartDate').value; + const stopVal = document.getElementById('simStopDate').value; + + const config = { + startDate: new Date(startVal).getTime() / 1000, + stopDate: stopVal ? new Date(stopVal).getTime() / 1000 : Math.floor(Date.now() / 1000), + autoDirection: document.getElementById('simAutoDirection').checked, + contractType: document.getElementById('simContractType').value, + direction: document.getElementById('simDirection').value, + capital: parseFloat(document.getElementById('simCapital').value), + exchangeLeverage: parseFloat(document.getElementById('simExchangeLeverage').value), + maxEffectiveLeverage: parseFloat(document.getElementById('simMaxEffectiveLeverage').value), + posSize: parseFloat(document.getElementById('simPosSize').value), + tp: parseFloat(document.getElementById('simTP').value) / 100, + openIndicators: Array.from(document.querySelectorAll('.sim-open-check:checked')).map(el => el.dataset.id), + closeIndicators: Array.from(document.querySelectorAll('.sim-close-check:checked')).map(el => el.dataset.id) + }; + + if (config.openIndicators.length === 0) { + alert('Please choose at least one indicator for opening positions.'); + return; + } + + const interval = window.dashboard?.currentInterval || '1d'; + + // 1. Ensure data is loaded for the range + let allCandles = window.dashboard?.allData?.get(interval) || []; + + const earliestInCache = allCandles.length > 0 ? allCandles[0].time : Infinity; + const latestInCache = allCandles.length > 0 ? allCandles[allCandles.length - 1].time : -Infinity; + + if (config.startDate < earliestInCache || config.stopDate > latestInCache) { + btn.textContent = 'Fetching from Server...'; + + let currentEndISO = new Date(config.stopDate * 1000).toISOString(); + const startISO = new Date(config.startDate * 1000).toISOString(); + let keepFetching = true; + let newCandlesAdded = false; + + while (keepFetching) { + const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${interval}&start=${startISO}&end=${currentEndISO}&limit=10000`); + const data = await response.json(); + + if (data.candles && data.candles.length > 0) { + const fetchedCandles = data.candles.reverse().map(c => ({ + time: Math.floor(new Date(c.time).getTime() / 1000), + open: parseFloat(c.open), + high: parseFloat(c.high), + low: parseFloat(c.low), + close: parseFloat(c.close), + volume: parseFloat(c.volume || 0) + })); + + allCandles = window.dashboard.mergeData(allCandles, fetchedCandles); + newCandlesAdded = true; + + // If we received 10000 candles, there might be more. We fetch again using the oldest candle's time - 1s + if (data.candles.length === 10000) { + const oldestTime = fetchedCandles[0].time; + if (oldestTime <= config.startDate) { + keepFetching = false; + } else { + currentEndISO = new Date((oldestTime - 1) * 1000).toISOString(); + btn.textContent = `Fetching older data... (${new Date(oldestTime * 1000).toLocaleDateString()})`; + } + } else { + keepFetching = false; + } + } else { + keepFetching = false; + } + } + + if (newCandlesAdded) { + window.dashboard.allData.set(interval, allCandles); + window.dashboard.candleSeries.setData(allCandles); + + btn.textContent = 'Calculating Indicators...'; + window.drawIndicatorsOnChart?.(); + await new Promise(r => setTimeout(r, 500)); + } + } + + // --- Auto-Direction: Fetch 1D candles for MA(44) --- + let dailyCandles = []; + let dailyMaMap = new Map(); // timestamp (midnight UTC) -> MA44 value + + if (config.autoDirection) { + btn.textContent = 'Fetching 1D MA(44)...'; + // Fetch 1D candles starting 45 days BEFORE the simulation start date to warm up the MA + const msPerDay = 24 * 60 * 60 * 1000; + const dailyStartISO = new Date((config.startDate * 1000) - (45 * msPerDay)).toISOString(); + const stopISO = new Date(config.stopDate * 1000).toISOString(); + + const dailyResponse = await fetch(`/api/v1/candles?symbol=BTC&interval=1d&start=${dailyStartISO}&end=${stopISO}&limit=5000`); + const dailyData = await dailyResponse.json(); + + if (dailyData.candles && dailyData.candles.length > 0) { + dailyCandles = dailyData.candles.reverse().map(c => ({ + time: Math.floor(new Date(c.time).getTime() / 1000), + close: parseFloat(c.close) + })); + + // Calculate MA(44) + const maPeriod = 44; + for (let i = maPeriod - 1; i < dailyCandles.length; i++) { + let sum = 0; + for (let j = 0; j < maPeriod; j++) { + sum += dailyCandles[i - j].close; + } + const maValue = sum / maPeriod; + // Store the MA value using the midnight UTC timestamp of that day + dailyMaMap.set(dailyCandles[i].time, maValue); + } + } else { + console.warn('[Simulation] Failed to fetch 1D candles for Auto-Direction. Falling back to manual.'); + config.autoDirection = false; + } + } + // -------------------------------------------------- + + btn.textContent = 'Simulating...'; + + // Filter candles by the exact range + const simCandles = allCandles.filter(c => c.time >= config.startDate && c.time <= config.stopDate); + + if (simCandles.length === 0) { + alert('No data available for the selected range.'); + return; + } + + // Calculate indicator signals + const indicatorSignals = {}; + for (const indId of [...new Set([...config.openIndicators, ...config.closeIndicators])]) { + const ind = activeIndicators.find(a => a.id === indId); + if (!ind) continue; + + const signalFunc = getSignalFunction(ind.type); + const results = ind.cachedResults; + + if (results && signalFunc) { + indicatorSignals[indId] = simCandles.map(candle => { + const idx = allCandles.findIndex(c => c.time === candle.time); + if (idx < 1) return null; + const values = typeof results[idx] === 'object' && results[idx] !== null ? results[idx] : { ma: results[idx] }; + const prevValues = typeof results[idx-1] === 'object' && results[idx-1] !== null ? results[idx-1] : { ma: results[idx-1] }; + return signalFunc(ind, allCandles[idx], allCandles[idx-1], values, prevValues); + }); + } + } + + // Simulation Initial State + const startPrice = simCandles[0].open; + + // We maintain a single "walletBalanceUsd" variable as the source of truth for the account size + let walletBalanceUsd = config.capital; + + // At any given time, the active margin type determines how we use this balance + // When LONG (Inverse), we theoretically buy BTC with it. + // When SHORT (Linear), we just use it as USDT. + + // Set initial state based on auto or manual + if (config.autoDirection && dailyMaMap.size > 0) { + // Find the MA value for the day before start date + const simStartDayTime = Math.floor(simCandles[0].time / 86400) * 86400; // Midnight UTC + let closestMA = Array.from(dailyMaMap.entries()) + .filter(([t]) => t <= simStartDayTime) + .sort((a,b) => b[0] - a[0])[0]; + + if (closestMA) { + const price = simCandles[0].open; + if (price > closestMA[1]) { + config.direction = 'long'; + config.contractType = 'inverse'; + } else { + config.direction = 'short'; + config.contractType = 'linear'; + } + } + } + + let equityData = { usd: [], btc: [] }; + let totalQty = 0; // Linear: BTC Contracts, Inverse: USD Contracts + let avgPrice = 0; + let avgPriceData = []; + let posSizeData = { btc: [], usd: [] }; + let trades = []; + + let currentDayStart = Math.floor(simCandles[0].time / 86400) * 86400; + + const PARTIAL_EXIT_PCT = 0.15; + const MIN_POSITION_VALUE_USD = 15; + + for (let i = 0; i < simCandles.length; i++) { + const candle = simCandles[i]; + const price = candle.close; + let actionTakenInThisCandle = false; + + // --- Auto-Direction Daily Check (Midnight UTC) --- + if (config.autoDirection) { + const candleDayStart = Math.floor(candle.time / 86400) * 86400; + if (candleDayStart > currentDayStart) { + currentDayStart = candleDayStart; + // It's a new day! Get yesterday's MA(44) + let closestMA = Array.from(dailyMaMap.entries()) + .filter(([t]) => t < currentDayStart) + .sort((a,b) => b[0] - a[0])[0]; + + if (closestMA) { + const maValue = closestMA[1]; + let newDirection = config.direction; + let newContractType = config.contractType; + + if (candle.open > maValue) { + newDirection = 'long'; + newContractType = 'inverse'; + } else { + newDirection = 'short'; + newContractType = 'linear'; + } + + // Did the trend flip? + if (newDirection !== config.direction) { + // Force close open position at candle.open (market open) + if (totalQty > 0) { + let pnlUsd = 0; + if (config.contractType === 'linear') { + pnlUsd = config.direction === 'long' ? (candle.open - avgPrice) * totalQty : (avgPrice - candle.open) * totalQty; + walletBalanceUsd += pnlUsd; + } else { // inverse + // PnL in BTC, converted back to USD + const pnlBtc = config.direction === 'long' + ? totalQty * (1/avgPrice - 1/candle.open) + : totalQty * (1/candle.open - 1/avgPrice); + // Inverse margin is BTC, so balance was in BTC. + // But we maintain walletBalanceUsd, so we just add the USD value of the PNL + pnlUsd = pnlBtc * candle.open; + walletBalanceUsd += pnlUsd; + } + + trades.push({ + type: config.direction, recordType: 'exit', time: candle.time, + entryPrice: avgPrice, exitPrice: candle.open, pnl: pnlUsd, reason: 'Force Close (Trend Flip)', + currentUsd: 0, currentQty: 0 + }); + totalQty = 0; + avgPrice = 0; + } + + // Apply flip + config.direction = newDirection; + config.contractType = newContractType; + } + } + } + } + // ------------------------------------------------ + + // 1. Check TP + if (totalQty > 0) { + let isTP = false; + let exitPrice = price; + if (config.direction === 'long') { + if (candle.high >= avgPrice * (1 + config.tp)) { + isTP = true; + exitPrice = avgPrice * (1 + config.tp); + } + } else { + if (candle.low <= avgPrice * (1 - config.tp)) { + isTP = true; + exitPrice = avgPrice * (1 - config.tp); + } + } + + if (isTP) { + let qtyToClose = totalQty * PARTIAL_EXIT_PCT; + let remainingQty = totalQty - qtyToClose; + let remainingValueUsd = config.contractType === 'linear' ? remainingQty * exitPrice : remainingQty; + let reason = 'TP (Partial)'; + + if (remainingValueUsd < MIN_POSITION_VALUE_USD) { + qtyToClose = totalQty; + reason = 'TP (Full - Min Size)'; + } + + let pnlUsd; + if (config.contractType === 'linear') { + pnlUsd = config.direction === 'long' ? (exitPrice - avgPrice) * qtyToClose : (avgPrice - exitPrice) * qtyToClose; + walletBalanceUsd += pnlUsd; + } else { + const pnlBtc = config.direction === 'long' + ? qtyToClose * (1/avgPrice - 1/exitPrice) + : qtyToClose * (1/exitPrice - 1/avgPrice); + pnlUsd = pnlBtc * exitPrice; + walletBalanceUsd += pnlUsd; + } + + totalQty -= qtyToClose; + trades.push({ + type: config.direction, recordType: 'exit', time: candle.time, + entryPrice: avgPrice, exitPrice: exitPrice, pnl: pnlUsd, reason: reason, + currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty, + currentQty: config.contractType === 'linear' ? totalQty : totalQty / price + }); + actionTakenInThisCandle = true; + } + } + + // 2. Check Close Signals + if (!actionTakenInThisCandle && totalQty > 0 && config.closeIndicators.length > 0) { + const hasCloseSignal = config.closeIndicators.some(id => { + const sig = indicatorSignals[id][i]; + if (!sig) return false; + return config.direction === 'long' ? sig.type === 'sell' : sig.type === 'buy'; + }); + + if (hasCloseSignal) { + let qtyToClose = totalQty * PARTIAL_EXIT_PCT; + let remainingQty = totalQty - qtyToClose; + let remainingValueUsd = config.contractType === 'linear' ? remainingQty * price : remainingQty; + let reason = 'Signal (Partial)'; + + if (remainingValueUsd < MIN_POSITION_VALUE_USD) { + qtyToClose = totalQty; + reason = 'Signal (Full - Min Size)'; + } + + let pnlUsd; + if (config.contractType === 'linear') { + pnlUsd = config.direction === 'long' ? (price - avgPrice) * qtyToClose : (avgPrice - price) * qtyToClose; + walletBalanceUsd += pnlUsd; + } else { + const pnlBtc = config.direction === 'long' + ? qtyToClose * (1/avgPrice - 1/price) + : qtyToClose * (1/price - 1/avgPrice); + pnlUsd = pnlBtc * price; + walletBalanceUsd += pnlUsd; + } + + totalQty -= qtyToClose; + trades.push({ + type: config.direction, recordType: 'exit', time: candle.time, + entryPrice: avgPrice, exitPrice: price, pnl: pnlUsd, reason: reason, + currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty, + currentQty: config.contractType === 'linear' ? totalQty : totalQty / price + }); + actionTakenInThisCandle = true; + } + } + + // Calculate Current Equity for Margin Check + let currentEquityUsd = walletBalanceUsd; + if (totalQty > 0) { + if (config.contractType === 'linear') { + currentEquityUsd += config.direction === 'long' ? (price - avgPrice) * totalQty : (avgPrice - price) * totalQty; + } else { + const upnlBtc = config.direction === 'long' ? totalQty * (1/avgPrice - 1/price) : totalQty * (1/price - 1/avgPrice); + currentEquityUsd += (upnlBtc * price); + } + } + + // 3. Check Open Signals + if (!actionTakenInThisCandle) { + const hasOpenSignal = config.openIndicators.some(id => { + const sig = indicatorSignals[id][i]; + if (!sig) return false; + return config.direction === 'long' ? sig.type === 'buy' : sig.type === 'sell'; + }); + + if (hasOpenSignal) { + const entryValUsd = config.posSize * config.exchangeLeverage; + const currentNotionalUsd = config.contractType === 'linear' ? totalQty * price : totalQty; + + const projectedEffectiveLeverage = (currentNotionalUsd + entryValUsd) / Math.max(currentEquityUsd, 0.0000001); + + if (projectedEffectiveLeverage <= config.maxEffectiveLeverage) { + if (config.contractType === 'linear') { + const entryQty = entryValUsd / price; + avgPrice = ((totalQty * avgPrice) + (entryQty * price)) / (totalQty + entryQty); + totalQty += entryQty; + } else { + avgPrice = (totalQty + entryValUsd) / ((totalQty / avgPrice || 0) + (entryValUsd / price)); + totalQty += entryValUsd; + } + + trades.push({ + type: config.direction, recordType: 'entry', time: candle.time, + entryPrice: price, reason: 'Entry', + currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty, + currentQty: config.contractType === 'linear' ? totalQty : totalQty / price + }); + } + } + } + + // Final Equity Recording + let finalEquityUsd = walletBalanceUsd; + if (totalQty > 0) { + if (config.contractType === 'linear') { + finalEquityUsd += config.direction === 'long' ? (price - avgPrice) * totalQty : (avgPrice - price) * totalQty; + } else { + const upnlBtc = config.direction === 'long' ? totalQty * (1/avgPrice - 1/price) : totalQty * (1/price - 1/avgPrice); + finalEquityUsd += (upnlBtc * price); + } + } + let finalEquityBtc = finalEquityUsd / price; + + equityData.usd.push({ time: candle.time, value: finalEquityUsd }); + equityData.btc.push({ time: candle.time, value: finalEquityBtc }); + + if (totalQty > 0.000001) { + avgPriceData.push({ + time: candle.time, + value: avgPrice, + color: config.direction === 'long' ? '#26a69a' : '#ef5350' // Green for long, Red for short + }); + } + posSizeData.btc.push({ time: candle.time, value: config.contractType === 'linear' ? totalQty : totalQty / price }); + posSizeData.usd.push({ time: candle.time, value: config.contractType === 'linear' ? totalQty * price : totalQty }); + } + + displayResultsCallback(trades, equityData, config, simCandles[simCandles.length-1].close, avgPriceData, posSizeData); + + } catch (error) { + console.error('[Simulation] Error:', error); + alert('Simulation failed.'); + } finally { + btn.disabled = false; + btn.textContent = 'Run Simulation'; + } + } +}; diff --git a/js/ui/chart.js b/js/ui/chart.js new file mode 100644 index 0000000..7ac055d --- /dev/null +++ b/js/ui/chart.js @@ -0,0 +1,1061 @@ +import { INTERVALS, COLORS } from '../core/index.js'; +import { calculateAllIndicatorSignals, calculateSummarySignal } from './signals-calculator.js'; +import { calculateSignalMarkers } from './signal-markers.js'; +import { updateIndicatorCandles } from './indicators-panel-new.js'; +import { TimezoneConfig } from '../config/timezone.js'; + +export class SeriesMarkersPrimitive { + constructor(markers) { + this._markers = markers || []; + this._paneViews = [new MarkersPaneView(this)]; + } + + setMarkers(markers) { + this._markers = markers; + if (this._requestUpdate) { + this._requestUpdate(); + } + } + + attached(param) { + this._chart = param.chart; + this._series = param.series; + this._requestUpdate = param.requestUpdate; + this._requestUpdate(); + } + + detached() { + this._chart = undefined; + this._series = undefined; + this._requestUpdate = undefined; + } + + updateAllViews() { + this._requestUpdate?.(); + } + + paneViews() { + return this._paneViews; + } +} + +class MarkersPaneView { + constructor(source) { + this._source = source; + } + + renderer() { + return new MarkersRenderer(this._source); + } + + zOrder() { + return 'top'; + } +} + +class MarkersRenderer { + constructor(source) { + this._source = source; + } + + draw(target) { + if (!this._source._chart || !this._source._series) return; + + target.useBitmapCoordinateSpace((scope) => { + const ctx = scope.context; + const series = this._source._series; + const chart = this._source._chart; + const markers = this._source._markers; + + // Adjust coordinates to bitmap space based on pixel ratio + const ratio = scope.horizontalPixelRatio; + + ctx.save(); + + for (const marker of markers) { + const timeCoordinate = chart.timeScale().timeToCoordinate(marker.time); + if (timeCoordinate === null) continue; + + // Figure out price coordinate + let price = marker.price || marker.value; + + // If price wasn't specified but we have the series data, grab the candle high/low + if (!price && window.dashboard && window.dashboard.allData) { + const data = window.dashboard.allData.get(window.dashboard.currentInterval); + if (data) { + const candle = data.find(d => d.time === marker.time); + if (candle) { + price = marker.position === 'aboveBar' ? candle.high : candle.low; + } + } + } + + if (!price) continue; + + const priceCoordinate = series.priceToCoordinate(price); + if (priceCoordinate === null) continue; + + const x = timeCoordinate * ratio; + const size = 5 * ratio; + const margin = 15 * ratio; + const isAbove = marker.position === 'aboveBar'; + const y = (isAbove ? priceCoordinate * ratio - margin : priceCoordinate * ratio + margin); + + ctx.fillStyle = marker.color || '#26a69a'; + ctx.beginPath(); + + const shape = marker.shape || (isAbove ? 'arrowDown' : 'arrowUp'); + + if (shape === 'arrowUp' || shape === 'triangleUp') { + ctx.moveTo(x, y - size); + ctx.lineTo(x - size, y + size); + ctx.lineTo(x + size, y + size); + } else if (shape === 'arrowDown' || shape === 'triangleDown') { + ctx.moveTo(x, y + size); + ctx.lineTo(x - size, y - size); + ctx.lineTo(x + size, y - size); + } else if (shape === 'circle') { + ctx.arc(x, y, size, 0, Math.PI * 2); + } else if (shape === 'square') { + ctx.rect(x - size, y - size, size * 2, size * 2); + } else if (shape === 'custom' && marker.text) { + ctx.font = `${Math.round(14 * ratio)}px Arial`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(marker.text, x, y); + continue; + } + ctx.fill(); + } + ctx.restore(); + }); + } +} + +function formatDate(timestamp) { + return TimezoneConfig.formatDate(timestamp); +} + +export class TradingDashboard { +constructor() { + this.chart = null; + this.candleSeries = null; + this.currentInterval = '1d'; + this.intervals = INTERVALS; + this.allData = new Map(); + this.isLoading = false; + this.hasInitialLoad = false; + this.taData = null; + this.indicatorSignals = []; + this.summarySignal = null; + this.lastCandleTimestamp = null; + this.simulationMarkers = []; + this.avgPriceSeries = null; + this.dailyMAData = new Map(); // timestamp -> { ma44, ma125, price } + this.currentMouseTime = null; + + this.init(); + } + + async loadDailyMAData() { + try { + // Use 1d interval for this calculation + const interval = '1d'; + let candles = this.allData.get(interval); + + if (!candles || candles.length < 125) { + const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${interval}&limit=1000`); + const data = await response.json(); + if (data.candles && data.candles.length > 0) { + candles = data.candles.reverse().map(c => ({ + time: Math.floor(new Date(c.time).getTime() / 1000), + open: parseFloat(c.open), + high: parseFloat(c.high), + low: parseFloat(c.low), + close: parseFloat(c.close) + })); + this.allData.set(interval, candles); + } + } + + if (candles && candles.length >= 44) { + const ma44 = this.calculateSimpleSMA(candles, 44); + const ma125 = this.calculateSimpleSMA(candles, 125); + + this.dailyMAData.clear(); + candles.forEach((c, i) => { + this.dailyMAData.set(c.time, { + price: c.close, + ma44: ma44[i], + ma125: ma125[i] + }); + }); + } + } catch (error) { + console.error('[DailyMA] Error:', error); + } + } + + calculateSimpleSMA(candles, period) { + const results = new Array(candles.length).fill(null); + let sum = 0; + for (let i = 0; i < candles.length; i++) { + sum += candles[i].close; + if (i >= period) sum -= candles[i - period].close; + if (i >= period - 1) results[i] = sum / period; + } + return results; + } + + setSimulationMarkers(markers) { + this.simulationMarkers = markers || []; + this.updateSignalMarkers(); + } + + clearSimulationMarkers() { + this.simulationMarkers = []; + this.updateSignalMarkers(); + } + + setAvgPriceData(data) { + if (this.avgPriceSeries) { + this.chart.removeSeries(this.avgPriceSeries); + } + + // Recreate series to apply custom colors per point via LineSeries data + this.avgPriceSeries = this.chart.addSeries(LightweightCharts.LineSeries, { + lineWidth: 2, + lineStyle: LightweightCharts.LineStyle.Solid, + lastValueVisible: true, + priceLineVisible: false, + crosshairMarkerVisible: false, + title: 'Avg Price', + }); + + this.avgPriceSeries.setData(data || []); + } + + clearAvgPriceData() { + if (this.avgPriceSeries) { + this.avgPriceSeries.setData([]); + } + } + + init() { + this.createTimeframeButtons(); + this.initChart(); + this.initEventListeners(); + this.loadInitialData(); + + setInterval(() => { + this.loadNewData(); + this.loadStats(); + if (new Date().getSeconds() < 15) this.loadTA(); + }, 10000); + } + + isAtRightEdge() { + const timeScale = this.chart.timeScale(); + const visibleRange = timeScale.getVisibleLogicalRange(); + if (!visibleRange) return true; + + const data = this.candleSeries.data(); + if (!data || data.length === 0) return true; + + return visibleRange.to >= data.length - 5; + } + + createTimeframeButtons() { + const container = document.getElementById('timeframeContainer'); + container.innerHTML = ''; + this.intervals.forEach(interval => { + const btn = document.createElement('button'); + btn.className = 'timeframe-btn'; + btn.dataset.interval = interval; + btn.textContent = interval; + if (interval === this.currentInterval) { + btn.classList.add('active'); + } + btn.addEventListener('click', () => this.switchTimeframe(interval)); + container.appendChild(btn); + }); + } + + initChart() { + const chartContainer = document.getElementById('chart'); + + this.chart = LightweightCharts.createChart(chartContainer, { + layout: { + background: { color: COLORS.tvBg }, + textColor: COLORS.tvText, + panes: { + background: { color: '#1e222d' }, + separatorColor: '#2a2e39', + separatorHoverColor: '#363c4e', + enableResize: true + } + }, + grid: { + vertLines: { color: '#363d4e' }, + horzLines: { color: '#363d4e' }, + }, + rightPriceScale: { + borderColor: '#363d4e', + autoScale: true, + }, + timeScale: { + borderColor: '#363d4e', + timeVisible: true, + secondsVisible: false, + rightOffset: 12, + barSpacing: 10, + tickMarkFormatter: (time, tickMarkType, locale) => { + return TimezoneConfig.formatTickMark(time); + }, + }, + localization: { + timeFormatter: (timestamp) => { + return TimezoneConfig.formatDate(timestamp * 1000); + }, + }, + handleScroll: { + vertTouchDrag: false, + }, + }); + + this.candleSeries = this.chart.addSeries(LightweightCharts.CandlestickSeries, { + upColor: '#ff9800', + downColor: '#ff9800', + borderUpColor: '#ff9800', + borderDownColor: '#ff9800', + wickUpColor: '#ff9800', + wickDownColor: '#ff9800', + lastValueVisible: false, + priceLineVisible: false, + }, 0); + + this.avgPriceSeries = this.chart.addSeries(LightweightCharts.LineSeries, { + color: '#00bcd4', + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Solid, + lastValueVisible: true, + priceLineVisible: false, + crosshairMarkerVisible: false, + title: '', + }); + + this.currentPriceLine = this.candleSeries.createPriceLine({ + price: 0, + color: '#26a69a', + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Dotted, + axisLabelVisible: true, + title: '', + }); + + this.initPriceScaleControls(); + this.initNavigationControls(); + + this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.onVisibleRangeChange.bind(this)); + + // Subscribe to crosshair movement for Best Moving Averages updates + this.chart.subscribeCrosshairMove(param => { + if (param.time) { + this.currentMouseTime = param.time; + this.renderTA(); + } else { + this.currentMouseTime = null; + this.renderTA(); + } + }); + + window.addEventListener('resize', () => { + this.chart.applyOptions({ + width: chartContainer.clientWidth, + height: chartContainer.clientHeight, + }); + }); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + this.loadNewData(); + this.loadTA(); + } + }); + window.addEventListener('focus', () => { + this.loadNewData(); + this.loadTA(); + }); + } + + initPriceScaleControls() { + const btnAutoScale = document.getElementById('btnAutoScale'); + const btnLogScale = document.getElementById('btnLogScale'); + + if (!btnAutoScale || !btnLogScale) return; + + this.priceScaleState = { + autoScale: true, + logScale: false + }; + + btnAutoScale.addEventListener('click', () => { + this.priceScaleState.autoScale = !this.priceScaleState.autoScale; + btnAutoScale.classList.toggle('active', this.priceScaleState.autoScale); + + this.candleSeries.priceScale().applyOptions({ + autoScale: this.priceScaleState.autoScale + }); + + console.log('Auto Scale:', this.priceScaleState.autoScale ? 'ON' : 'OFF'); + }); + + btnLogScale.addEventListener('click', () => { + this.priceScaleState.logScale = !this.priceScaleState.logScale; + btnLogScale.classList.toggle('active', this.priceScaleState.logScale); + + let currentPriceRange = null; + let currentTimeRange = null; + if (!this.priceScaleState.autoScale) { + try { + currentPriceRange = this.candleSeries.priceScale().getVisiblePriceRange(); + } catch (e) { + console.log('Could not get price range'); + } + } + try { + currentTimeRange = this.chart.timeScale().getVisibleLogicalRange(); + } catch (e) { + console.log('Could not get time range'); + } + + this.candleSeries.priceScale().applyOptions({ + mode: this.priceScaleState.logScale ? LightweightCharts.PriceScaleMode.Logarithmic : LightweightCharts.PriceScaleMode.Normal + }); + + this.chart.applyOptions({}); + + setTimeout(() => { + if (currentTimeRange) { + try { + this.chart.timeScale().setVisibleLogicalRange(currentTimeRange); + } catch (e) { + console.log('Could not restore time range'); + } + } + + if (!this.priceScaleState.autoScale && currentPriceRange) { + try { + this.candleSeries.priceScale().setVisiblePriceRange(currentPriceRange); + } catch (e) { + console.log('Could not restore price range'); + } + } + }, 100); + + console.log('Log Scale:', this.priceScaleState.logScale ? 'ON' : 'OFF'); + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'a' || e.key === 'A') { + if (e.target.tagName !== 'INPUT') { + btnAutoScale.click(); + } + } + }); + } + + initNavigationControls() { + const chartWrapper = document.getElementById('chartWrapper'); + const navLeft = document.getElementById('navLeft'); + const navRight = document.getElementById('navRight'); + const navRecent = document.getElementById('navRecent'); + + if (!chartWrapper || !navLeft || !navRight || !navRecent) return; + + chartWrapper.addEventListener('mousemove', (e) => { + const rect = chartWrapper.getBoundingClientRect(); + const distanceFromBottom = rect.bottom - e.clientY; + chartWrapper.classList.toggle('show-nav', distanceFromBottom < 30); + }); + + chartWrapper.addEventListener('mouseleave', () => { + chartWrapper.classList.remove('show-nav'); + }); + + navLeft.addEventListener('click', () => this.navigateLeft()); + navRight.addEventListener('click', () => this.navigateRight()); + navRecent.addEventListener('click', () => this.navigateToRecent()); + } + + navigateLeft() { + const visibleRange = this.chart.timeScale().getVisibleLogicalRange(); + if (!visibleRange) return; + + const visibleBars = visibleRange.to - visibleRange.from; + const shift = visibleBars * 0.8; + const newFrom = visibleRange.from - shift; + const newTo = visibleRange.to - shift; + + this.chart.timeScale().setVisibleLogicalRange({ from: newFrom, to: newTo }); + } + + navigateRight() { + const visibleRange = this.chart.timeScale().getVisibleLogicalRange(); + if (!visibleRange) return; + + const visibleBars = visibleRange.to - visibleRange.from; + const shift = visibleBars * 0.8; + const newFrom = visibleRange.from + shift; + const newTo = visibleRange.to + shift; + + this.chart.timeScale().setVisibleLogicalRange({ from: newFrom, to: newTo }); + } + + navigateToRecent() { + this.chart.timeScale().scrollToRealTime(); + } + + initEventListeners() { + document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return; + + const shortcuts = { + '1': '1m', '2': '3m', '3': '5m', '4': '15m', '5': '30m', '7': '37m', + '6': '1h', '8': '4h', '9': '8h', '0': '12h', + 'd': '1d', 'D': '1d', 'w': '1w', 'W': '1w', 'm': '1M', 'M': '1M' + }; + + if (shortcuts[e.key]) { + this.switchTimeframe(shortcuts[e.key]); + } + + if (e.key === 'ArrowLeft') { + this.navigateLeft(); + } else if (e.key === 'ArrowRight') { + this.navigateRight(); + } else if (e.key === 'ArrowUp') { + this.navigateToRecent(); + } +}); + } + + clearIndicatorCaches(clearSignalState = false) { + const activeIndicators = window.getActiveIndicators?.() || []; + activeIndicators.forEach(indicator => { + // Always clear calculation caches + indicator.cachedResults = null; + indicator.cachedMeta = null; + + // Only clear signal state if explicitly requested (e.g., timeframe change) + // Do not clear on new candle completion - preserve signal change tracking + if (clearSignalState) { + indicator.lastSignalTimestamp = null; + indicator.lastSignalType = null; + } + }); + console.log(`[Dashboard] Cleared caches for ${activeIndicators.length} indicators (signals: ${clearSignalState})`); + } + + async loadInitialData() { + await Promise.all([ + this.loadData(2000, true), + this.loadStats(), + this.loadDailyMAData() + ]); + this.hasInitialLoad = true; + this.loadTA(); + } + + async loadData(limit = 1000, fitToContent = false) { + if (this.isLoading) return; + this.isLoading = true; + + try { + const visibleRange = this.chart.timeScale().getVisibleLogicalRange(); + + const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&limit=${limit}`); + const data = await response.json(); + +if (data.candles && data.candles.length > 0) { + const chartData = data.candles.reverse().map(c => ({ + time: Math.floor(new Date(c.time).getTime() / 1000), + open: parseFloat(c.open), + high: parseFloat(c.high), + low: parseFloat(c.low), + close: parseFloat(c.close), + volume: parseFloat(c.volume || 0) + })); + + const existingData = this.allData.get(this.currentInterval) || []; + const mergedData = this.mergeData(existingData, chartData); + this.allData.set(this.currentInterval, mergedData); + + this.candleSeries.setData(mergedData); + + if (fitToContent) { + this.chart.timeScale().scrollToRealTime(); + } else if (visibleRange) { + this.chart.timeScale().setVisibleLogicalRange(visibleRange); + } + + const latest = mergedData[mergedData.length - 1]; + this.updateStats(latest); + } + + window.drawIndicatorsOnChart?.(); + } catch (error) { + console.error('Error loading data:', error); + } finally { + this.isLoading = false; + } + } + +async loadNewData() { + if (!this.hasInitialLoad || this.isLoading) return; + + try { + const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&limit=50`); + const data = await response.json(); + + if (data.candles && data.candles.length > 0) { + const atEdge = this.isAtRightEdge(); + + const currentSeriesData = this.candleSeries.data(); + const lastTimestamp = currentSeriesData.length > 0 + ? currentSeriesData[currentSeriesData.length - 1].time + : 0; + + const chartData = data.candles.reverse().map(c => ({ + time: Math.floor(new Date(c.time).getTime() / 1000), + open: parseFloat(c.open), + high: parseFloat(c.high), + low: parseFloat(c.low), + close: parseFloat(c.close), + volume: parseFloat(c.volume || 0) + })); + + const latest = chartData[chartData.length - 1]; + + // Check if new candle detected + const isNewCandle = this.lastCandleTimestamp !== null && latest.time > this.lastCandleTimestamp; + + if (isNewCandle) { + console.log(`[NewData Load] New candle detected: ${this.lastCandleTimestamp} -> ${latest.time}`); + // Clear indicator caches but preserve signal state + this.clearIndicatorCaches(false); + } + + this.lastCandleTimestamp = latest.time; + + chartData.forEach(candle => { + if (candle.time >= lastTimestamp && + !Number.isNaN(candle.time) && + !Number.isNaN(candle.open) && + !Number.isNaN(candle.high) && + !Number.isNaN(candle.low) && + !Number.isNaN(candle.close)) { + this.candleSeries.update(candle); + } + }); + + const existingData = this.allData.get(this.currentInterval) || []; + this.allData.set(this.currentInterval, this.mergeData(existingData, chartData)); + + //console.log(`[NewData Load] Added ${chartData.length} new candles, total in dataset: ${this.allData.get(this.currentInterval).length}`); + + if (atEdge) { + this.chart.timeScale().scrollToRealTime(); + } + + this.updateStats(latest); + + //console.log('[Chart] Calling drawIndicatorsOnChart after new data'); + window.drawIndicatorsOnChart?.(); + window.updateIndicatorCandles?.(); + + this.loadDailyMAData(); + await this.loadSignals(); + } + } catch (error) { + console.error('Error loading new data:', error); + } + } + + mergeData(existing, newData) { + const dataMap = new Map(); + existing.forEach(c => dataMap.set(c.time, c)); + newData.forEach(c => dataMap.set(c.time, c)); + return Array.from(dataMap.values()).sort((a, b) => a.time - b.time); + } + +onVisibleRangeChange() { + if (!this.hasInitialLoad || this.isLoading) { + return; + } + + const visibleRange = this.chart.timeScale().getVisibleLogicalRange(); + if (!visibleRange) { + return; + } + + const data = this.candleSeries.data(); + const allData = this.allData.get(this.currentInterval); + + if (!data || data.length === 0) { + return; + } + + const visibleBars = Math.ceil(visibleRange.to - visibleRange.from); + const bufferSize = visibleBars * 2; + const refillThreshold = bufferSize * 0.8; + const barsFromLeft = Math.floor(visibleRange.from); + const visibleOldestTime = data[Math.floor(visibleRange.from)]?.time; + const visibleNewestTime = data[Math.ceil(visibleRange.to)]?.time; + + console.log(`[VisibleRange] Visible: ${visibleBars} bars (${data.length} in chart, ${allData?.length || 0} in dataset)`); + console.log(`[VisibleRange] Time range: ${new Date((visibleOldestTime || 0) * 1000).toLocaleDateString()} to ${new Date((visibleNewestTime || 0) * 1000).toLocaleDateString()}`); + + if (barsFromLeft < refillThreshold) { + console.log(`Buffer low (${barsFromLeft} < ${refillThreshold.toFixed(0)}), prefetching ${bufferSize} candles...`); + const oldestCandle = data[0]; + if (oldestCandle) { + this.loadHistoricalData(oldestCandle.time, bufferSize); + } + } + + // Recalculate indicators when data changes + if (data.length !== allData?.length) { + console.log(`[VisibleRange] Chart data (${data.length}) vs dataset (${allData?.length || 0}) differ, redrawing indicators...`); + } + + window.drawIndicatorsOnChart?.(); + this.loadSignals().catch(e => console.error('Error loading signals:', e)); + } + +async loadHistoricalData(beforeTime, limit = 1000) { + if (this.isLoading) { + return; + } + this.isLoading = true; + + console.log(`[Historical] Loading historical data before ${new Date(beforeTime * 1000).toLocaleDateString()}, limit=${limit}`); + + try { + const endTime = new Date((beforeTime - 1) * 1000); + + const response = await fetch( + `/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&end=${endTime.toISOString()}&limit=${limit}` + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + if (data.candles && data.candles.length > 0) { + const chartData = data.candles.reverse().map(c => ({ + time: Math.floor(new Date(c.time).getTime() / 1000), + open: parseFloat(c.open), + high: parseFloat(c.high), + low: parseFloat(c.low), + close: parseFloat(c.close), + volume: parseFloat(c.volume || 0) + })); + + const existingData = this.allData.get(this.currentInterval) || []; + const mergedData = this.mergeData(existingData, chartData); + this.allData.set(this.currentInterval, mergedData); + + console.log(`[Historical] SUCCESS: Added ${chartData.length} candles`); + console.log(`[Historical] Total candles in dataset: ${mergedData.length}`); + console.log(`[Historical] Oldest: ${new Date(mergedData[0]?.time * 1000).toLocaleDateString()}`); + console.log(`[Historical] Newest: ${new Date(mergedData[mergedData.length - 1]?.time * 1000).toLocaleDateString()}`); + + this.candleSeries.setData(mergedData); + + // Recalculate indicators and signals with the expanded dataset + console.log(`[Historical] Recalculating indicators...`); + window.drawIndicatorsOnChart?.(); + await this.loadSignals(); + + console.log(`[Historical] Indicators recalculated for ${mergedData.length} candles`); + } else { + console.log('[Historical] No more historical data available from database'); + } + } catch (error) { + console.error('[Historical] Error loading historical data:', error); + } finally { + this.isLoading = false; + } + } + +async loadTA() { + if (!this.hasInitialLoad) { + const time = new Date().toLocaleTimeString(); + document.getElementById('taContent').innerHTML = `
Loading technical analysis... ${time}
`; + return; + } + + try { + const response = await fetch(`/api/v1/ta?symbol=BTC&interval=${this.currentInterval}`); + const data = await response.json(); + + if (data.error) { + document.getElementById('taContent').innerHTML = `
${data.error}
`; + return; + } + + this.taData = data; + await this.loadSignals(); + this.renderTA(); + } catch (error) { + console.error('Error loading TA:', error); + document.getElementById('taContent').innerHTML = '
Failed to load technical analysis. Please check if the database has candle data.
'; + } + } + +async loadSignals() { + try { + this.indicatorSignals = calculateAllIndicatorSignals(); + this.summarySignal = calculateSummarySignal(this.indicatorSignals); + this.updateSignalMarkers(); + } catch (error) { + console.error('Error loading signals:', error); + this.indicatorSignals = []; + this.summarySignal = null; + } + } + + updateSignalMarkers() { + const candles = this.allData.get(this.currentInterval); + if (!candles || candles.length === 0) return; + + let markers = calculateSignalMarkers(candles); + + // Merge simulation markers if present + if (this.simulationMarkers && this.simulationMarkers.length > 0) { + markers = [...markers, ...this.simulationMarkers]; + } + + // CRITICAL: Filter out any markers with invalid timestamps before passing to chart + markers = markers.filter(m => m && m.time !== null && m.time !== undefined && !isNaN(m.time)); + + // Re-sort combined markers by time + markers.sort((a, b) => a.time - b.time); + + // Use custom primitive for markers in v5 + try { + if (!this.markerPrimitive) { + this.markerPrimitive = new SeriesMarkersPrimitive(); + this.candleSeries.attachPrimitive(this.markerPrimitive); + } + this.markerPrimitive.setMarkers(markers); + } catch (e) { + console.warn('[SignalMarkers] setMarkers primitive error:', e.message); + } + } + + renderTA() { + if (!this.taData || this.taData.error) { + document.getElementById('taContent').innerHTML = `
${this.taData?.error || 'No data available'}
`; + return; + } + + const data = this.taData; + const trendClass = data.trend.direction.toLowerCase(); + const signalClass = data.trend.signal.toLowerCase(); + + document.getElementById('taInterval').textContent = this.currentInterval.toUpperCase(); + document.getElementById('taLastUpdate').textContent = new Date().toLocaleTimeString(); + + const summary = this.summarySignal || {}; + const summarySignalClass = summary.signal || 'hold'; + + const signalsHtml = this.indicatorSignals?.length > 0 ? this.indicatorSignals.map(indSignal => { + const signalIcon = indSignal.signal === 'buy' ? '🟢' : indSignal.signal === 'sell' ? '🔴' : '⚪'; + const signalColor = indSignal.signal === 'buy' ? '#26a69a' : indSignal.signal === 'sell' ? '#ef5350' : '#787b86'; + const lastSignalDate = indSignal.lastSignalDate ? formatDate(indSignal.lastSignalDate * 1000) : '-'; + + // Format params as "MA(44)" style + let paramsStr = ''; + if (indSignal.params !== null && indSignal.params !== undefined) { + paramsStr = `(${indSignal.params})`; + } + + return ` +
+ ${indSignal.name}${paramsStr} + + ${signalIcon} ${indSignal.signal.toUpperCase()} + ${lastSignalDate} + +
+ `; + }).join('') : ''; + + const summaryBadge = ''; + + // Best Moving Averages Logic (1D based) + let displayMA = { ma44: null, ma125: null, price: null, time: null }; + + if (this.currentMouseTime && this.dailyMAData.size > 0) { + // Find the 1D candle that includes this mouse time + const dayTimestamp = Math.floor(this.currentMouseTime / 86400) * 86400; + if (this.dailyMAData.has(dayTimestamp)) { + displayMA = { ...this.dailyMAData.get(dayTimestamp), time: dayTimestamp }; + } else { + // Fallback to latest if specific day not found + const keys = Array.from(this.dailyMAData.keys()).sort((a, b) => b - a); + const latestKey = keys[0]; + displayMA = { ...this.dailyMAData.get(latestKey), time: latestKey }; + } + } else if (this.dailyMAData.size > 0) { + const keys = Array.from(this.dailyMAData.keys()).sort((a, b) => b - a); + const latestKey = keys[0]; + displayMA = { ...this.dailyMAData.get(latestKey), time: latestKey }; + } + + const ma44Value = displayMA.ma44; + const ma125Value = displayMA.ma125; + const currentPrice = displayMA.price; + + const ma44Change = (ma44Value && currentPrice) ? ((currentPrice - ma44Value) / ma44Value * 100) : null; + const ma125Change = (ma125Value && currentPrice) ? ((currentPrice - ma125Value) / ma125Value * 100) : null; + const maDateStr = displayMA.time ? TimezoneConfig.formatDate(displayMA.time * 1000).split(' ')[0] : 'Latest'; + + document.getElementById('taContent').innerHTML = ` +
+
+ Indicator Analysis + ${summaryBadge} +
+ ${signalsHtml ? signalsHtml : `
No indicators selected. Add indicators from the sidebar panel to view signals.
`} +
+ +
+
+ Best Moving Averages + ${maDateStr} (1D) +
+
+ MA 44 + + ${ma44Value ? ma44Value.toFixed(2) : 'N/A'} + ${ma44Change !== null ? `${ma44Change >= 0 ? '+' : ''}${ma44Change.toFixed(1)}%` : ''} + +
+
+ MA 125 + + ${ma125Value ? ma125Value.toFixed(2) : 'N/A'} + ${ma125Change !== null ? `${ma125Change >= 0 ? '+' : ''}${ma125Change.toFixed(1)}%` : ''} + +
+
+ +
+
Support / Resistance
+
+ Resistance + ${data.levels.resistance.toFixed(2)} +
+
+ Support + ${data.levels.support.toFixed(2)} +
+
+ +
+
Price Position
+
+
+
+
+ ${data.levels.position_in_range.toFixed(0)}% in range +
+
+ `; + } + + renderSignalsSection() { + return ''; + } + + async loadStats() { + try { + const response = await fetch('/api/v1/stats?symbol=BTC'); + this.statsData = await response.json(); + } catch (error) { + console.error('Error loading stats:', error); + } + } + + updateStats(candle) { + const price = candle.close; + const isUp = candle.close >= candle.open; + + if (this.currentPriceLine) { + this.currentPriceLine.applyOptions({ + price: price, + color: isUp ? '#26a69a' : '#ef5350', + }); + } + + document.getElementById('currentPrice').textContent = price.toFixed(2); + + if (this.statsData) { + const change = this.statsData.change_24h; + document.getElementById('currentPrice').className = 'stat-value ' + (change >= 0 ? 'positive' : 'negative'); + document.getElementById('priceChange').textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%'; + document.getElementById('priceChange').className = 'stat-value ' + (change >= 0 ? 'positive' : 'negative'); + document.getElementById('dailyHigh').textContent = this.statsData.high_24h.toFixed(2); + document.getElementById('dailyLow').textContent = this.statsData.low_24h.toFixed(2); +} + } + +switchTimeframe(interval) { + if (!this.intervals.includes(interval) || interval === this.currentInterval) return; + + const oldInterval = this.currentInterval; + this.currentInterval = interval; + this.hasInitialLoad = false; + + document.querySelectorAll('.timeframe-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.interval === interval); + }); + + // Clear indicator caches and signal state before switching timeframe + this.clearIndicatorCaches(true); + + // Clear old interval data, not new interval + this.allData.delete(oldInterval); + this.lastCandleTimestamp = null; + + this.loadInitialData(); + + window.clearSimulationResults?.(); + window.updateTimeframeDisplay?.(); + + // Notify indicators of timeframe change for recalculation + window.onTimeframeChange?.(interval); + } +} + +export function refreshTA() { + if (window.dashboard) { + const time = new Date().toLocaleTimeString(); + document.getElementById('taContent').innerHTML = `
Refreshing... ${time}
`; + window.dashboard.loadTA(); + } +} + +export function openAIAnalysis() { + const symbol = 'BTC'; + const interval = window.dashboard?.currentInterval || '1d'; + const prompt = `Analyze Bitcoin (${symbol}) ${interval} chart. Current trend, support/resistance levels, and trading recommendation. Technical indicators: MA44, MA125.`; + + const geminiUrl = `https://gemini.google.com/app?prompt=${encodeURIComponent(prompt)}`; + window.open(geminiUrl, '_blank'); +} diff --git a/js/ui/hts-visualizer.js b/js/ui/hts-visualizer.js new file mode 100644 index 0000000..f476ec6 --- /dev/null +++ b/js/ui/hts-visualizer.js @@ -0,0 +1,243 @@ +const HTS_COLORS = { + fastHigh: '#00bcd4', + fastLow: '#00bcd4', + slowHigh: '#f44336', + slowLow: '#f44336', + bullishZone: 'rgba(38, 166, 154, 0.1)', + bearishZone: 'rgba(239, 83, 80, 0.1)', + channelRegion: 'rgba(41, 98, 255, 0.05)' +}; + +let HTSOverlays = []; + +export class HTSVisualizer { + constructor(chart, candles) { + this.chart = chart; + this.candles = candles; + this.overlays = []; + } + + clear() { + this.overlays.forEach(overlay => { + try { + this.chart.removeSeries(overlay.series); + } catch (e) { } + }); + this.overlays = []; + } + + addHTSChannels(htsData, isAutoHTS = false) { + this.clear(); + + if (!htsData || htsData.length === 0) return; + + const alpha = isAutoHTS ? 0.3 : 0.3; + const lineWidth = isAutoHTS ? 1 : 2; + + const fastHighSeries = this.chart.addSeries(LightweightCharts.LineSeries, { + color: `rgba(0, 188, 212, ${alpha})`, + lineWidth: lineWidth, + lastValueVisible: false, + title: 'HTS Fast High' + (isAutoHTS ? ' (Auto)' : ''), + priceLineVisible: false, + crosshairMarkerVisible: false + }); + + const fastLowSeries = this.chart.addSeries(LightweightCharts.LineSeries, { + color: `rgba(0, 188, 212, ${alpha})`, + lineWidth: lineWidth, + lastValueVisible: false, + title: 'HTS Fast Low' + (isAutoHTS ? ' (Auto)' : ''), + priceLineVisible: false, + crosshairMarkerVisible: false + }); + + const slowHighSeries = this.chart.addSeries(LightweightCharts.LineSeries, { + color: `rgba(244, 67, 54, ${alpha})`, + lineWidth: lineWidth + 1, + lastValueVisible: false, + title: 'HTS Slow High' + (isAutoHTS ? ' (Auto)' : ''), + priceLineVisible: false, + crosshairMarkerVisible: false + }); + + const slowLowSeries = this.chart.addSeries(LightweightCharts.LineSeries, { + color: `rgba(244, 67, 54, ${alpha})`, + lineWidth: lineWidth + 1, + lastValueVisible: false, + title: 'HTS Slow Low' + (isAutoHTS ? ' (Auto)' : ''), + priceLineVisible: false, + crosshairMarkerVisible: false + }); + + const fastHighData = htsData.map(h => ({ time: h.time, value: h.fastHigh })); + const fastLowData = htsData.map(h => ({ time: h.time, value: h.fastLow })); + const slowHighData = htsData.map(h => ({ time: h.time, value: h.slowHigh })); + const slowLowData = htsData.map(h => ({ time: h.time, value: h.slowLow })); + + fastHighSeries.setData(fastHighData); + fastLowSeries.setData(fastLowData); + slowHighSeries.setData(slowHighData); + slowLowSeries.setData(slowLowData); + + this.overlays.push( + { series: fastHighSeries, name: 'fastHigh' }, + { series: fastLowSeries, name: 'fastLow' }, + { series: slowHighSeries, name: 'slowHigh' }, + { series: slowLowSeries, name: 'slowLow' } + ); + + return { + fastHigh: fastHighSeries, + fastLow: fastLowSeries, + slowHigh: slowHighSeries, + slowLow: slowLowSeries + }; + } + + addTrendZones(htsData) { + if (!htsData || htsData.length < 2) return; + + const trendZones = []; + let currentZone = null; + + for (let i = 1; i < htsData.length; i++) { + const prev = htsData[i - 1]; + const curr = htsData[i]; + + const prevBullish = prev.fastLow > prev.slowLow && prev.fastHigh > prev.slowHigh; + const currBullish = curr.fastLow > curr.slowLow && curr.fastHigh > curr.slowHigh; + + const prevBearish = prev.fastLow < prev.slowLow && prev.fastHigh < prev.slowHigh; + const currBearish = curr.fastLow < curr.slowLow && curr.fastHigh < curr.slowHigh; + + if (currBullish && !prevBullish) { + currentZone = { type: 'bullish', start: curr.time }; + } else if (currBearish && !prevBearish) { + currentZone = { type: 'bearish', start: curr.time }; + } else if (!currBullish && !currBearish && currentZone) { + currentZone.end = prev.time; + trendZones.push({ ...currentZone }); + currentZone = null; + } + } + + if (currentZone) { + currentZone.end = htsData[htsData.length - 1].time; + trendZones.push(currentZone); + } + + trendZones.forEach(zone => { + const zoneSeries = this.chart.addSeries(LightweightCharts.AreaSeries, { + topColor: zone.type === 'bullish' ? 'rgba(38, 166, 154, 0.02)' : 'rgba(239, 83, 80, 0.02)', + bottomColor: zone.type === 'bullish' ? 'rgba(38, 166, 154, 0.02)' : 'rgba(239, 83, 80, 0.02)', + lineColor: 'transparent', + lastValueVisible: false, + priceLineVisible: false, + }); + + if (this.candles && this.candles.length > 0) { + const maxPrice = Math.max(...this.candles.map(c => c.high)) * 2; + const minPrice = Math.min(...this.candles.map(c => c.low)) * 0.5; + + const startTime = zone.start || (this.candles[0]?.time); + const endTime = zone.end || (this.candles[this.candles.length - 1]?.time); + + zoneSeries.setData([ + { time: startTime, value: minPrice }, + { time: startTime, value: maxPrice }, + { time: endTime, value: maxPrice }, + { time: endTime, value: minPrice } + ]); + } + + this.overlays.push({ series: zoneSeries, name: `trendZone_${zone.type}_${zone.start}` }); + }); + } + + addCrossoverMarkers(htsData) { + if (!htsData || htsData.length < 2) return; + + const markers = []; + + for (let i = 1; i < htsData.length; i++) { + const prev = htsData[i - 1]; + const curr = htsData[i]; + + if (!prev || !curr) continue; + + const price = curr.price; + + const prevFastLow = prev.fastLow; + const currFastLow = curr.fastLow; + const prevFastHigh = prev.fastHigh; + const currFastHigh = curr.fastHigh; + const prevSlowLow = prev.slowLow; + const currSlowLow = curr.slowLow; + const prevSlowHigh = prev.slowHigh; + const currSlowHigh = curr.slowHigh; + + if (prevFastLow <= prevSlowLow && currFastLow > currSlowLow && price > currSlowLow) { + markers.push({ + time: curr.time, + position: 'belowBar', + color: '#26a69a', + shape: 'arrowUp', + text: 'BUY', + size: 1.2 + }); + } + + if (prevFastHigh >= prevSlowHigh && currFastHigh < currSlowHigh && price < currSlowHigh) { + markers.push({ + time: curr.time, + position: 'aboveBar', + color: '#ef5350', + shape: 'arrowDown', + text: 'SELL', + size: 1.2 + }); + } + } + + const candleSeries = this.candleData?.series; + if (candleSeries) { + try { + if (typeof candleSeries.setMarkers === 'function') { + candleSeries.setMarkers(markers); + } else if (typeof SeriesMarkersPrimitive !== 'undefined') { + if (!this.markerPrimitive) { + this.markerPrimitive = new SeriesMarkersPrimitive(); + candleSeries.attachPrimitive(this.markerPrimitive); + } + this.markerPrimitive.setMarkers(markers); + } + } catch (e) { + console.warn('[HTS] Error setting markers:', e); + } + } + + return markers; + } +} + +export function addHTSVisualization(chart, candleSeries, htsData, candles, isAutoHTS = false) { + const visualizer = new HTSVisualizer(chart, candles); + visualizer.candleData = { series: candleSeries }; + visualizer.addHTSChannels(htsData, isAutoHTS); + + // Disable trend zones to avoid visual clutter + // visualizer.addTrendZones(htsData); + + if (window.showCrossoverMarkers !== false) { + setTimeout(() => { + try { + visualizer.addCrossoverMarkers(htsData); + } catch (e) { + console.warn('Crossover markers skipped (API limitation):', e.message); + } + }, 100); + } + + return visualizer; +} \ No newline at end of file diff --git a/js/ui/index.js b/js/ui/index.js new file mode 100644 index 0000000..a007e51 --- /dev/null +++ b/js/ui/index.js @@ -0,0 +1,14 @@ +export { TradingDashboard, refreshTA, openAIAnalysis } from './chart.js'; +export { toggleSidebar, restoreSidebarState } from './sidebar.js'; +export { + renderIndicatorList, + addNewIndicator, + selectIndicator, + renderIndicatorConfig, + applyIndicatorConfig, + removeIndicator, + removeIndicatorByIndex, + drawIndicatorsOnChart, + getActiveIndicators, + setActiveIndicators +} from './indicators-panel.js'; diff --git a/js/ui/indicators-panel-new.js b/js/ui/indicators-panel-new.js new file mode 100644 index 0000000..5d2038b --- /dev/null +++ b/js/ui/indicators-panel-new.js @@ -0,0 +1,1200 @@ +import { getAvailableIndicators, IndicatorRegistry as IR } from '../indicators/index.js'; + +// State management +let activeIndicators = []; + +console.log('[Module] indicators-panel-new.js loaded - activeIndicators count:', activeIndicators?.length || 0); +let configuringId = null; +let searchQuery = ''; +let selectedCategory = 'all'; +let nextInstanceId = 1; +let listenersAttached = false; // Single flag to track if any listeners are attached + +// Chart pane management +let indicatorPanes = new Map(); +let nextPaneIndex = 1; + +// Presets storage +let userPresets; +try { + userPresets = JSON.parse(localStorage.getItem('indicator_presets') || '{}'); + if (!userPresets || typeof userPresets !== 'object') { + userPresets = { presets: [] }; + } +} catch (e) { + console.warn('Failed to parse presets:', e); + userPresets = { presets: [] }; +} + +// Categories +const CATEGORIES = [ + { id: 'all', name: 'All Indicators', icon: '📊' }, + { id: 'trend', name: 'Trend', icon: '📊' }, + { id: 'momentum', name: 'Momentum', icon: '📈' }, + { id: 'volatility', name: 'Volatility', icon: '📉' }, + { id: 'volume', name: 'Volume', icon: '🔀' }, + { id: 'favorites', name: 'Favorites', icon: '★' } +]; + +const CATEGORY_MAP = { + sma: 'trend', ema: 'trend', hts: 'trend', + rsi: 'momentum', macd: 'momentum', stoch: 'momentum', + bb: 'volatility', atr: 'volatility', + others: 'volume' +}; + +const DEFAULT_COLORS = ['#2962ff', '#26a69a', '#ef5350', '#ff9800', '#9c27b0', '#00bcd4', '#ffeb3b', '#e91e63']; +const LINE_TYPES = ['solid', 'dotted', 'dashed']; + +function getDefaultColor(index) { + return DEFAULT_COLORS[index % DEFAULT_COLORS.length]; +} + +function getIndicatorCategory(indicator) { + return CATEGORY_MAP[indicator.type] || 'trend'; +} + +function getIndicatorLabel(indicator) { + const meta = getIndicatorMeta(indicator); + if (!meta) return indicator.name; + + // Always show params in parentheses (e.g., "MA(44)" or "MA(SMA,44)") + const paramParts = meta.inputs.map(input => { + const val = indicator.params[input.name]; + return val !== undefined ? val : input.default; + }); + + if (paramParts.length > 0) { + return `${indicator.name}(${paramParts.join(',')})`; + } + return indicator.name; +} + +function getIndicatorMeta(indicator) { + const IndicatorClass = IR?.[indicator.type]; + if (!IndicatorClass) return null; + const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name }); + return instance.getMetadata(); +} + +function groupPlotsByColor(plots) { + const groups = {}; + plots.forEach((plot, idx) => { + const groupMap = { + 'fast': 'Fast', 'slow': 'Slow', 'upper': 'Upper', 'lower': 'Lower', + 'middle': 'Middle', 'basis': 'Middle', 'signal': 'Signal', + 'histogram': 'Histogram', 'k': '%K', 'd': '%D' + }; + const groupName = Object.entries(groupMap).find(([k, v]) => plot.id.toLowerCase().includes(k))?.[1] || plot.id; + if (!groups[groupName]) { + groups[groupName] = { name: groupName, indices: [], plots: [] }; + } + groups[groupName].indices.push(idx); + groups[groupName].plots.push(plot); + }); + return Object.values(groups); +} + +export function initIndicatorPanel() { + renderIndicatorPanel(); +} + +export function getActiveIndicators() { + return activeIndicators; +} + +export function setActiveIndicators(indicators) { + console.warn('setActiveIndicators() called with', indicators.length, 'indicators - this will replace activeIndicators array!'); + console.trace('Call stack:'); + activeIndicators = indicators; + renderIndicatorPanel(); +} + +window.getActiveIndicators = getActiveIndicators; + +async function onTimeframeChange(newInterval) { + const indicators = getActiveIndicators(); + for (const indicator of indicators) { + if (indicator.params.timeframe === 'chart' && typeof indicator.shouldRecalculate === 'function') { + if (indicator.shouldRecalculate()) { + try { + await window.renderIndicator(indicator.id); + } catch (err) { + console.error(`[onTimeframeChange] Failed to recalculate ${indicator.name}:`, err); + } + } + } + } +} + +window.onTimeframeChange = onTimeframeChange; + +// Render main panel +export function renderIndicatorPanel() { + const container = document.getElementById('indicatorPanel'); + if (!container) { + return; + } + + const available = getAvailableIndicators(); + const catalog = available.filter(ind => { + if (searchQuery && !ind.name.toLowerCase().includes(searchQuery.toLowerCase())) return false; + if (selectedCategory === 'all') return true; + if (selectedCategory === 'favorites') return false; + const cat = CATEGORY_MAP[ind.type] || 'trend'; + return cat === selectedCategory; + }); + + const favoriteIds = new Set(userPresets.favorites || []); + + const allVisible = activeIndicators.length > 0 ? activeIndicators.every(ind => ind.visible !== false) : false; + const visibilityBtnText = allVisible ? 'Hide All' : 'Show All'; + + container.innerHTML = ` +
+ + + + +
+ ${CATEGORIES.map(cat => ` + + `).join('')} +
+ + + ${[...favoriteIds].length > 0 ? ` +
+
★ Favorites
+ ${[...favoriteIds].map(id => { + const ind = available.find(a => { + return a.type === id || (activeIndicators.find(ai => ai.id === id)?.type === ''); + }); + if (!ind) return ''; + return renderIndicatorItem(ind, true); + }).join('')} +
+ ` : ''} + + + ${activeIndicators.length > 0 ? ` +
+
+ ${activeIndicators.length} Active + ${activeIndicators.length > 0 ? `` : ''} + ${activeIndicators.length > 0 ? `` : ''} +
+ ${activeIndicators.slice().reverse().map(ind => renderActiveIndicator(ind)).join('')} +
+ ` : ''} + + + ${catalog.length > 0 ? ` +
+
Available Indicators
+ ${catalog.map(ind => renderIndicatorItem(ind, false)).join('')} +
+ ` : ` +
+ No indicators found +
+ `} +
+ `; + + // Only setup event listeners once + if (!listenersAttached) { + setupEventListeners(); + listenersAttached = true; + } +} + +function renderIndicatorItem(indicator, isFavorite) { + return ` +
+
+ ${indicator.name} + ${indicator.description || ''} +
+ + ${isFavorite ? '' : ` + + `} +
+
+
+ `; +} + +function renderActiveIndicator(indicator) { + const isExpanded = configuringId === indicator.id; + const meta = getIndicatorMeta(indicator); + const label = getIndicatorLabel(indicator); + const isFavorite = userPresets.favorites?.includes(indicator.type) || false; + const showPresets = meta.name && function() { + const hasPresets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : []; + if (!hasPresets || hasPresets.length === 0) return ''; + return `
+ +
`; + }(); + + return ` +
+
+
⋮⋮
+ + ${label} + ${showPresets} + + +
+ + ${isExpanded ? ` +
+ ${typeof renderIndicatorConfig === 'function' ? renderIndicatorConfig(indicator, meta) : ''} +
+ ` : ''} +
+ `; +} + +function renderPresetIndicatorIndicator(meta, indicator) { + const hasPresets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : []; + if (!hasPresets || hasPresets.length === 0) return ''; + + return ``; +} + +function renderIndicatorConfig(indicator, meta) { + const plotGroups = groupPlotsByColor(meta?.plots || []); + + return ` +
+ +
+
Visual Settings
+ ${plotGroups.map(group => { + const firstIdx = group.indices[0]; + const color = indicator.params[`_color_${firstIdx}`] || meta.plots[firstIdx]?.color || getDefaultColor(activeIndicators.indexOf(indicator)); + return ` +
+ +
+ + +
+
+ `.trim() + ''; + }).join('')} + + ${indicator.type !== 'rsi' ? ` +
+ + +
+ +
+ + + ${indicator.params._lineWidth || 2} +
+` : ''} +
+ + ${meta?.inputs && meta.inputs.length > 0 ? ` +
+
Parameters
+ ${meta.inputs.map(input => ` +
+ + ${input.type === 'select' ? + `` : + `` + } +
+ `).join('')} +
+ ` : ''} + +
+
Signals
+
+ + +
+
+ + + +
+
+ +
+ + +
+
+
+ + + +
+
+ +
+ + +
+
+
+ +
+
+ Presets + +
+ ${typeof renderIndicatorPresets === 'function' ? renderIndicatorPresets(indicator, meta) : ''} +
+ +
+ + +
+
+ `; +} + +function renderIndicatorPresets(indicator, meta) { + const presets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : []; + + return presets.length > 0 ? ` +
+ ${presets.map(p => { + const isApplied = meta.inputs.every(input => + (indicator.params[input.name] === (p.values?.[input.name] ?? input.default)) + ); + + return ` +
+ ${p.name} + +
+ `; + }).join('')} +
+ ` : '
No saved presets
'; +} + +// Event listeners +function setupEventListeners() { + const container = document.getElementById('indicatorPanel'); + if (!container) return; + + container.addEventListener('click', (e) => { + e.stopPropagation(); + + // Add button + const addBtn = e.target.closest('.indicator-btn.add'); + if (addBtn) { + e.stopPropagation(); + const type = addBtn.dataset.type; + if (type && window.addIndicator) { + window.addIndicator(type); + } + return; + } + + // Remove button + const removeBtn = e.target.closest('.indicator-btn.remove'); + if (removeBtn) { + e.stopPropagation(); + const id = removeBtn.dataset.id; + if (id && window.removeIndicatorById) { + window.removeIndicatorById(id); + } + return; + } + + // Clear all button + const clearAllBtn = e.target.closest('.clear-all'); + if (clearAllBtn) { + if (window.clearAllIndicators) { + window.clearAllIndicators(); + } + return; + } + + // Visibility toggle (Hide All / Show All) button + const visibilityToggleBtn = e.target.closest('.visibility-toggle'); + if (visibilityToggleBtn) { + if (window.toggleAllIndicatorsVisibility) { + window.toggleAllIndicatorsVisibility(); + } + return; + } + + // Expand/collapse button + const expandBtn = e.target.closest('.indicator-btn.expand'); + if (expandBtn) { + e.stopPropagation(); + const id = expandBtn.dataset.id; + if (id && window.toggleIndicatorExpand) { + window.toggleIndicatorExpand(id); + } + return; + } + + // Visibility button (eye) + const visibleBtn = e.target.closest('.indicator-btn.visible'); + if (visibleBtn) { + e.stopPropagation(); + const id = visibleBtn.dataset.id; + if (id && window.toggleIndicatorVisibility) { + window.toggleIndicatorVisibility(id); + } + return; + } + }); + + // Search input + const searchInput = document.getElementById('indicatorSearch'); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + searchQuery = e.target.value; + renderIndicatorPanel(); + }); + } + + // Search clear button + const searchClear = container.querySelector('.search-clear'); + if (searchClear) { + searchClear.addEventListener('click', (e) => { + searchQuery = ''; + renderIndicatorPanel(); + }); + } + + // Category tabs + document.querySelectorAll('.category-tab').forEach(tab => { + tab.addEventListener('click', (e) => { + selectedCategory = tab.dataset.category; + renderIndicatorPanel(); + }); + }); +} + +// Actions +window.toggleIndicatorExpand = function(id) { + configuringId = configuringId === id ? null : id; + renderIndicatorPanel(); +}; + +window.clearSearch = function() { + searchQuery = ''; + renderIndicatorPanel(); +}; + +window.updateIndicatorColor = function(id, index, color) { + const indicator = activeIndicators.find(a => a.id === id); + if (!indicator) return; + + indicator.params[`_color_${index}`] = color; + drawIndicatorsOnChart(); +}; + +window.updateIndicatorSetting = function(id, key, value) { + const indicator = activeIndicators.find(a => a.id === id); + if (!indicator) return; + + indicator.params[key] = value; + indicator.lastSignalTimestamp = null; + indicator.lastSignalType = null; + indicator.cachedResults = null; // Clear cache when params change + drawIndicatorsOnChart(); +}; + +window.clearAllIndicators = function() { + activeIndicators.forEach(ind => { + ind.series?.forEach(s => { + try { window.dashboard?.chart?.removeSeries(s); } catch(e) {} + }); + }); + activeIndicators = []; + configuringId = null; + renderIndicatorPanel(); + drawIndicatorsOnChart(); +} + +window.toggleAllIndicatorsVisibility = function() { + const allVisible = activeIndicators.every(ind => ind.visible !== false); + + activeIndicators.forEach(ind => { + ind.visible = !allVisible; + }); + + drawIndicatorsOnChart(); + renderIndicatorPanel(); +} + +function removeIndicatorById(id) { + const idx = activeIndicators.findIndex(a => a.id === id); + if (idx < 0) return; + + activeIndicators[idx].series?.forEach(s => { + try { window.dashboard?.chart?.removeSeries(s); } catch(e) {} + }); + + activeIndicators.splice(idx, 1); + + if (configuringId === id) { + configuringId = null; + } + + renderIndicatorPanel(); + drawIndicatorsOnChart(); +} + +// Presets +function getPresetsForIndicator(indicatorName) { + if (!userPresets || !userPresets.presets) return []; + return userPresets.presets.filter(p => p.indicatorName === indicatorName); +} + +window.savePreset = function(id) { + console.log('[savePreset] Attempting to save preset for id:', id); + const indicator = activeIndicators.find(a => a.id === id); + if (!indicator) { + console.error('[savePreset] Indicator not found for id:', id); + return; + } + + const presetName = prompt('Enter preset name:'); + if (!presetName) return; + + const IndicatorClass = IR?.[indicator.type]; + if (!IndicatorClass) { + console.error('[savePreset] Indicator class not found for type:', indicator.type); + return; + } + + try { + const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name }); + const meta = instance.getMetadata(); + + const preset = { + id: `preset_${Date.now()}`, + name: presetName, + indicatorName: meta.name, + values: {} + }; + + // Save standard inputs + if (meta.inputs && Array.isArray(meta.inputs)) { + meta.inputs.forEach(input => { + preset.values[input.name] = indicator.params[input.name]; + }); + } + + // Save visual settings (line width, type, colors) + preset.values._lineWidth = indicator.params._lineWidth; + preset.values._lineType = indicator.params._lineType; + + // Save colors for each plot + if (meta.plots && Array.isArray(meta.plots)) { + meta.plots.forEach((plot, idx) => { + const colorKey = `_color_${idx}`; + if (indicator.params[colorKey]) { + preset.values[colorKey] = indicator.params[colorKey]; + } + }); + } + + // Save marker settings + const markerKeys = [ + 'showMarkers', + 'markerBuyShape', 'markerBuyColor', 'markerBuyCustom', + 'markerSellShape', 'markerSellColor', 'markerSellCustom' + ]; + markerKeys.forEach(key => { + if (indicator.params[key] !== undefined) { + preset.values[key] = indicator.params[key]; + } + }); + + if (!userPresets) userPresets = { presets: [] }; + if (!userPresets.presets) userPresets.presets = []; + + userPresets.presets.push(preset); + saveUserPresets(); + renderIndicatorPanel(); + + alert(`Preset "${presetName}" saved!`); + } catch (error) { + console.error('[savePreset] Error saving preset:', error); + alert('Error saving preset. See console for details.'); + } +}; + +window.applyPreset = function(id, presetId) { + const allPresets = (userPresets?.presets || []).filter(p => typeof p === 'object' && p.id); + const preset = allPresets.find(p => p.id === presetId); + if (!preset) return; + + const indicator = activeIndicators.find(a => a.id === id); + if (!indicator) return; + + Object.keys(preset.values).forEach(key => { + indicator.params[key] = preset.values[key]; + }); + + renderIndicatorPanel(); + drawIndicatorsOnChart(); +}; + +window.deletePreset = function(presetId) { + if (!confirm('Delete this preset?')) return; + + if (userPresets?.presets) { + userPresets.presets = userPresets.presets.filter(p => p.id !== presetId); + saveUserPresets(); + renderIndicatorPanel(); + } +}; + +window.showPresets = function(indicatorName) { + const presets = getPresetsForIndicator(indicatorName); + if (presets.length === 0) { + alert('No saved presets for this indicator'); + return; + } + + const menu = window.open('', '_blank', 'width=400,height=500'); + + let htmlContent = + 'Presets - ' + indicatorName + '' + + '

' + indicatorName + ' Presets

'; + + presets.forEach(p => { + htmlContent += '
' + p.name + '
'; + }); + + htmlContent += ''; + + menu.document.write(htmlContent); +}; + +window.applyPresetFromWindow = function(presetId) { + const indicator = activeIndicators.find(a => a.id === configuringId); + if (!indicator) return; + applyPreset(indicator.id, presetId); +}; + +function addIndicator(type) { + const IndicatorClass = IR?.[type]; + if (!IndicatorClass) return; + + const id = `${type}_${nextInstanceId++}`; + const instance = new IndicatorClass({ type, params: {}, name: '' }); + const metadata = instance.getMetadata(); + + const params = { + _lineType: 'solid', + _lineWidth: 1, + showMarkers: true, + markerBuyShape: 'custom', + markerBuyColor: '#9e9e9e', + markerBuyCustom: '▲', + markerSellShape: 'custom', + markerSellColor: '#9e9e9e', + markerSellCustom: '▼' + }; + + // Override with Hurst-specific defaults + if (type === 'hurst') { + params._lineWidth = 1; + params.timeframe = 'chart'; + params.markerBuyShape = 'custom'; + params.markerSellShape = 'custom'; + params.markerBuyColor = '#9e9e9e'; + params.markerSellColor = '#9e9e9e'; + params.markerBuyCustom = '▲'; + params.markerSellCustom = '▼'; + } + + metadata.plots.forEach((plot, idx) => { + params[`_color_${idx}`] = plot.color || getDefaultColor(activeIndicators.length + idx); + }); + metadata.inputs.forEach(input => { + params[input.name] = input.default; + }); + + activeIndicators.push({ + id, + type, + name: metadata.name, + params, + plots: metadata.plots, + series: [], + visible: true, + paneHeight: 120 // default 120px + }); + + // Don't set configuringId so indicators are NOT expanded by default + renderIndicatorPanel(); + drawIndicatorsOnChart(); +}; + +function saveUserPresets() { + localStorage.setItem('indicator_presets', JSON.stringify(userPresets)); +} + +function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) { + // Recalculate with current TF candles (or use cached if they exist and are the correct length) + let results = indicator.cachedResults; + if (!results || !Array.isArray(results) || results.length !== candles.length) { + console.log(`[renderIndicatorOnPane] ${indicator.name}: Calling instance.calculate()...`); + results = instance.calculate(candles); + indicator.cachedResults = results; + } + + if (!results || !Array.isArray(results)) { + console.error(`[renderIndicatorOnPane] ${indicator.name}: Failed to get valid results (got ${typeof results})`); + return; + } + + if (results.length !== candles.length) { + console.error(`[renderIndicatorOnPane] ${indicator.name}: MISMATCH! Expected ${candles.length} results but got ${results.length}`); + } + + // Clear previous series for this indicator + if (indicator.series && indicator.series.length > 0) { + indicator.series.forEach(s => { + try { + window.dashboard.chart.removeSeries(s); + } catch(e) {} + }); + } + indicator.series = []; + + const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid; + const lineWidth = indicator.params._lineWidth || 1; + + // Improved detection of object-based results (multiple plots) + const firstNonNull = Array.isArray(results) ? results.find(r => r !== null && r !== undefined) : null; + let isObjectResult = firstNonNull && typeof firstNonNull === 'object' && !Array.isArray(firstNonNull); + + // Fallback: If results are all null (e.g. during warmup or MTF fetch), + // use metadata to determine if it SHOULD be an object result + if (!firstNonNull && meta.plots && meta.plots.length > 1) { + isObjectResult = true; + } + // Also check if the only plot has a specific ID that isn't just a number + if (!firstNonNull && meta.plots && meta.plots.length === 1 && meta.plots[0].id !== 'value') { + isObjectResult = true; + } + + let plotsCreated = 0; + let dataPointsAdded = 0; + + meta.plots.forEach((plot, plotIdx) => { + if (isObjectResult) { + const hasData = results.some(r => r && r[plot.id] !== undefined && r[plot.id] !== null); + if (!hasData) return; + } + + const plotColor = indicator.params[`_color_${plotIdx}`] || plot.color || '#2962ff'; + + const data = []; + let firstDataIndex = -1; + + for (let i = 0; i < candles.length; i++) { + let value; + if (isObjectResult) { + value = results[i]?.[plot.id]; + } else { + value = results[i]; + } + + if (value !== null && value !== undefined && typeof value === 'number' && Number.isFinite(value)) { + if (firstDataIndex === -1) { + firstDataIndex = i; + } + data.push({ + time: candles[i].time, + value: value + }); + } + } + + console.log(`[renderIndicatorOnPane] ${indicator.name} plot ${plotIdx}: ${data.length} data points created, first data at index ${firstDataIndex}/${candles.length}`); + + if (data.length === 0) { + console.log(`[renderIndicatorOnPane] ${indicator.name} plot ${plotIdx}: No data to render`); + return; + } + + console.log(`[renderIndicatorOnPane] ${indicator.name} plot ${plotIdx}: Creating series with ${data.length} data points [${data[0].time} to ${data[data.length - 1].time}]`); + + let series; + let plotLineStyle = lineStyle; + if (plot.style === 'dashed') plotLineStyle = LightweightCharts.LineStyle.Dashed; + else if (plot.style === 'dotted') plotLineStyle = LightweightCharts.LineStyle.Dotted; + else if (plot.style === 'solid') plotLineStyle = LightweightCharts.LineStyle.Solid; + + if (plot.type === 'histogram') { + series = window.dashboard.chart.addSeries(LightweightCharts.HistogramSeries, { + color: plotColor, + priceFormat: { type: 'price', precision: 4, minMove: 0.0001 }, + priceLineVisible: false, + lastValueVisible: false + }, paneIndex); + } else if (plot.type === 'baseline') { + series = window.dashboard.chart.addSeries(LightweightCharts.BaselineSeries, { + baseValue: { type: 'price', price: plot.baseValue || 0 }, + topLineColor: plot.topLineColor || plotColor, + topFillColor1: plot.topFillColor1 || plotColor, + topFillColor2: '#00000000', + bottomFillColor1: '#00000000', + bottomColor: plot.bottomColor || '#00000000', + lineWidth: plot.width || indicator.params._lineWidth || lineWidth, + lineStyle: plotLineStyle, + title: plot.title || '', + priceLineVisible: false, + lastValueVisible: plot.lastValueVisible !== false + }, paneIndex); + } else { + series = window.dashboard.chart.addSeries(LightweightCharts.LineSeries, { + color: plotColor, + lineWidth: plot.width || indicator.params._lineWidth || lineWidth, + lineStyle: plotLineStyle, + title: plot.title || '', + priceLineVisible: false, + lastValueVisible: plot.lastValueVisible !== false + }, paneIndex); + } + + series.setData(data); + indicator.series.push(series); + plotsCreated++; + console.log(`Created series for ${indicator.id}, plot=${plot.id}, total series now=${indicator.series.length}`); + + // Create horizontal band lines for RSI + if (meta.name === 'RSI' && indicator.series.length > 0) { + const mainSeries = indicator.series[0]; + const overbought = indicator.params.overbought || 70; + const oversold = indicator.params.oversold || 30; + + // Remove existing price lines first + while (indicator.bands && indicator.bands.length > 0) { + try { + indicator.bands.pop(); + } catch(e) {} + } + indicator.bands = indicator.bands || []; + + // Create overbought band line + indicator.bands.push(mainSeries.createPriceLine({ + price: overbought, + color: '#787B86', + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Dashed, + axisLabelVisible: false, + title: '' + })); + + // Create oversold band line + indicator.bands.push(mainSeries.createPriceLine({ + price: oversold, + color: '#787B86', + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Dashed, + axisLabelVisible: false, + title: '' + })); + } + }); +} + +// Completely redraw indicators (works for both overlay and pane) +export function updateIndicatorCandles() { + console.log('[UpdateIndicators] Removing and recreating all indicator series'); + + // Remove all existing series + const activeIndicators = getActiveIndicators(); + activeIndicators.forEach(indicator => { + indicator.series?.forEach(s => { + try { + window.dashboard.chart.removeSeries(s); + } catch(e) { + console.warn('[UpdateIndicators] Error removing series:', e); + } + }); + indicator.series = []; + }); + + // Clear pane mappings + indicatorPanes.clear(); + nextPaneIndex = 1; + + // Now call drawIndicatorsOnChart to recreate everything + drawIndicatorsOnChart(); + + console.log(`[UpdateIndicators] Recreated ${activeIndicators.length} indicators`); +} + +// Chart drawing +export function drawIndicatorsOnChart() { + try { + if (!window.dashboard || !window.dashboard.chart) { + return; + } + + const currentInterval = window.dashboard.currentInterval; + const candles = window.dashboard?.allData?.get(currentInterval); + + if (!candles || candles.length === 0) { + //console.log('[Indicators] No candles available'); + return; + } + + // console.log(`[Indicators] ========== drawIndicatorsOnChart START ==========`); + // console.log(`[Indicators] Candles from allData: ${candles.length}`); + // console.log(`[Indicators] First candle time: ${candles[0]?.time} (${new Date(candles[0]?.time * 1000).toLocaleDateString()})`); + // console.log(`[Indicators] Last candle time: ${candles[candles.length - 1]?.time} (${new Date(candles[candles.length - 1]?.time * 1000).toLocaleDateString()})`); + + const oldestTime = candles[0]?.time; + const newestTime = candles[candles.length - 1]?.time; + const oldestDate = oldestTime ? new Date(oldestTime * 1000).toLocaleDateString() : 'N/A'; + const newestDate = newestTime ? new Date(newestTime * 1000).toLocaleDateString() : 'N/A'; + + //console.log(`[Indicators] ========== Redrawing ==========`); + // console.log(`[Indicators] Candles: ${candles.length} | Time range: ${oldestDate} (${oldestTime}) to ${newestDate} (${newestTime})`); + + const activeIndicators = getActiveIndicators(); + + // Remove all existing series + activeIndicators.forEach(ind => { + ind.series?.forEach(s => { + try { window.dashboard.chart.removeSeries(s); } catch(e) {} + }); + ind.series = []; + }); + + const lineStyleMap = { + 'solid': LightweightCharts.LineStyle.Solid, + 'dotted': LightweightCharts.LineStyle.Dotted, + 'dashed': LightweightCharts.LineStyle.Dashed + }; + + // Don't clear indicatorPanes - preserve pane assignments across redraws + // Only reset nextPaneIndex to avoid creating duplicate panes + const maxExistingPane = Math.max(...indicatorPanes.values(), 0); + nextPaneIndex = maxExistingPane + 1; + + const overlayIndicators = []; + const paneIndicators = []; + + // Process all indicators, filtering by visibility + activeIndicators.forEach(ind => { + if (ind.visible === false || ind.visible === 'false') { + return; + } + + const IndicatorClass = IR?.[ind.type]; + if (!IndicatorClass) return; + + const instance = new IndicatorClass(ind); + const meta = instance.getMetadata(); + +// Store calculated results and metadata for signal calculation + let results = ind.cachedResults; + if (!results || !Array.isArray(results) || results.length !== candles.length) { + try { + results = instance.calculate(candles); + ind.cachedResults = results; + } catch (err) { + console.error(`[Indicators] Failed to calculate ${ind.name}:`, err); + results = []; + } + } + ind.cachedMeta = meta; + + const validResults = Array.isArray(results) ? results.filter(r => r !== null && r !== undefined) : []; + const warmupPeriod = ind.params?.period || 44; + console.log(`[Indicators] ${ind.name}: ${validResults.length} valid results (need ${warmupPeriod} candles warmup)`); + + if (meta.displayMode === 'pane') { + paneIndicators.push({ indicator: ind, meta, instance }); + } else { + overlayIndicators.push({ indicator: ind, meta, instance }); + } + }); + + // Set main pane height (60% if indicator panes exist, 100% otherwise) + const totalPanes = 1 + paneIndicators.length; + const mainPaneHeight = paneIndicators.length > 0 ? 60 : 100; + + window.dashboard.chart.panes()[0]?.setHeight(mainPaneHeight); + + //console.log(`[Indicators] ========== Rendering Indicators ==========`); + //console.log(`[Indicators] Input candles: ${candles.length} | Panel count: ${totalPanes}`); + + overlayIndicators.forEach(({ indicator, meta, instance }) => { + //console.log(`[Indicators] Processing overlay: ${indicator.name}`); + //console.log(`[Indicators] Before renderIndicatorOnPane: indicator.cachedResults length = ${indicator.cachedResults?.length || 0}`); + renderIndicatorOnPane(indicator, meta, instance, candles, 0, lineStyleMap); + //console.log(`[Indicators] After renderIndicatorOnPane: indicator.cachedResults length = ${indicator.cachedResults?.length || 0}`); + }); + + paneIndicators.forEach(({ indicator, meta, instance }, idx) => { + // Use existing pane index if already assigned, otherwise create new one + let paneIndex = indicatorPanes.get(indicator.id); + if (paneIndex === undefined) { + paneIndex = nextPaneIndex++; + indicatorPanes.set(indicator.id, paneIndex); + } + + //console.log(`[Indicators] Processing pane: ${indicator.name} (pane ${paneIndex})`); + //console.log(`[Indicators] Before renderIndicatorOnPane: indicator.cachedResults length = ${indicator.cachedResults?.length || 0}`); + renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap); + //console.log(`[Indicators] After renderIndicatorOnPane: indicator.cachedResults length = ${indicator.cachedResults?.length || 0}`); + + const pane = window.dashboard.chart.panes()[paneIndex]; + if (pane) { + // Use stored height, localStorage, or default 120px + const storedHeight = indicator.paneHeight || + parseInt(localStorage.getItem(`pane_height_${indicator.type}`)) || + 120; + pane.setHeight(storedHeight); + } + }); + + //console.log(`[Indicators] ========== drawIndicatorsOnChart END ==========`); + } catch (error) { + console.error('[Indicators] Error drawing indicators:', error); + } + + // Update signal markers after indicators are drawn + if (window.dashboard && typeof window.dashboard.updateSignalMarkers === 'function') { + window.dashboard.updateSignalMarkers(); + } +} + +function resetIndicator(id) { + const indicator = activeIndicators.find(a => a.id === id); + if (!indicator) return; + + const IndicatorClass = IR[indicator.type]; + if (!IndicatorClass) return; + + const instance = new IndicatorClass({ type: indicator.type, params: {}, name: '' }); + const meta = instance.getMetadata(); + if (!meta || !meta.inputs) return; + + meta.inputs.forEach(input => { + indicator.params[input.name] = input.default; + }); + + renderIndicatorPanel(); + drawIndicatorsOnChart(); +} + +function removeIndicator(id) { + removeIndicatorById(id); +} + +function toggleIndicatorVisibility(id) { + const indicator = activeIndicators.find(a => a.id === id); + if (!indicator) { + return; + } + + indicator.visible = indicator.visible === false; + + // Full redraw to ensure all indicators render correctly + if (typeof drawIndicatorsOnChart === 'function') { + drawIndicatorsOnChart(); + } + + renderIndicatorPanel(); +} + +// Export functions for module access +export { addIndicator, removeIndicatorById, toggleIndicatorVisibility }; + +// Legacy compatibility functions +window.renderIndicatorList = renderIndicatorPanel; +window.resetIndicator = resetIndicator; +window.removeIndicator = removeIndicator; +window.toggleIndicator = addIndicator; +window.toggleIndicatorVisibility = toggleIndicatorVisibility; +window.showIndicatorConfig = function(id) { + const ind = activeIndicators.find(a => a.id === id); + if (ind) configuringId = id; + renderIndicatorPanel(); +}; +window.applyIndicatorConfig = function() { +// No-op - config is applied immediately +}; \ No newline at end of file diff --git a/js/ui/indicators-panel-new.js.bak b/js/ui/indicators-panel-new.js.bak new file mode 100644 index 0000000..d9702dc --- /dev/null +++ b/js/ui/indicators-panel-new.js.bak @@ -0,0 +1,868 @@ +import { getAvailableIndicators, IndicatorRegistry as IR } from '../indicators/index.js'; + +// State management +let activeIndicators = []; +let configuringId = null; +let searchQuery = ''; +let selectedCategory = 'all'; +let nextInstanceId = 1; +let listenersAttached = false; // Single flag to track if any listeners are attached + +// Chart pane management +let indicatorPanes = new Map(); +let nextPaneIndex = 1; + +// Presets storage +let userPresets = JSON.parse(localStorage.getItem('indicator_presets') || '{}'); + +// Categories +const CATEGORIES = [ + { id: 'all', name: 'All Indicators', icon: '📊' }, + { id: 'trend', name: 'Trend', icon: '📊' }, + { id: 'momentum', name: 'Momentum', icon: '📈' }, + { id: 'volatility', name: 'Volatility', icon: '📉' }, + { id: 'volume', name: 'Volume', icon: '🔀' }, + { id: 'favorites', name: 'Favorites', icon: '★' } +]; + +const CATEGORY_MAP = { + sma: 'trend', ema: 'trend', hts: 'trend', + rsi: 'momentum', macd: 'momentum', stoch: 'momentum', + bb: 'volatility', atr: 'volatility', + others: 'volume' +}; + +const DEFAULT_COLORS = ['#2962ff', '#26a69a', '#ef5350', '#ff9800', '#9c27b0', '#00bcd4', '#ffeb3b', '#e91e63']; +const LINE_TYPES = ['solid', 'dotted', 'dashed']; + +function getDefaultColor(index) { + return DEFAULT_COLORS[index % DEFAULT_COLORS.length]; +} + +function getIndicatorCategory(indicator) { + return CATEGORY_MAP[indicator.type] || 'trend'; +} + +function getIndicatorLabel(indicator) { + const meta = getIndicatorMeta(indicator); + if (!meta) return indicator.name; + + const paramParts = meta.inputs.map(input => { + const val = indicator.params[input.name]; + if (val !== undefined && val !== input.default) return val; + return null; + }).filter(v => v !== null); + + if (paramParts.length > 0) { + return `${indicator.name} (${paramParts.join(', ')})`; + } + return indicator.name; +} + +function getIndicatorMeta(indicator) { + const IndicatorClass = IR?.[indicator.type]; + if (!IndicatorClass) return null; + const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name }); + return instance.getMetadata(); +} + +function groupPlotsByColor(plots) { + const groups = {}; + plots.forEach((plot, idx) => { + const groupMap = { + 'fast': 'Fast', 'slow': 'Slow', 'upper': 'Upper', 'lower': 'Lower', + 'middle': 'Middle', 'basis': 'Middle', 'signal': 'Signal', + 'histogram': 'Histogram', 'k': '%K', 'd': '%D' + }; + const groupName = Object.entries(groupMap).find(([k, v]) => plot.id.toLowerCase().includes(k))?.[1] || plot.id; + if (!groups[groupName]) { + groups[groupName] = { name: groupName, indices: [], plots: [] }; + } + groups[groupName].indices.push(idx); + groups[groupName].plots.push(plot); + }); + return Object.values(groups); +} + +export function initIndicatorPanel() { + console.log('[IndicatorPanel] Initializing...'); + renderIndicatorPanel(); + console.log('[IndicatorPanel] Initialized'); +} + +export function getActiveIndicators() { + return activeIndicators; +} + +export function setActiveIndicators(indicators) { + activeIndicators = indicators; + renderIndicatorPanel(); +} + +// Render main panel +export function renderIndicatorPanel() { + const container = document.getElementById('indicatorPanel'); + if (!container) { + console.error('[IndicatorPanel] Container #indicatorPanel not found!'); + return; + } + + console.log('[IndicatorPanel] Rendering panel, searchQuery:', searchQuery, 'selectedCategory:', selectedCategory); + + const available = getAvailableIndicators(); + const catalog = available.filter(ind => { + if (searchQuery && !ind.name.toLowerCase().includes(searchQuery.toLowerCase())) return false; + if (selectedCategory === 'all') return true; + if (selectedCategory === 'favorites') return false; + const cat = CATEGORY_MAP[ind.type] || 'trend'; + return cat === selectedCategory; + }); + + console.log("[IndicatorPanel] Total indicators:", available.length, "Filtered to:", catalog.length); + + const favoriteIds = new Set(userPresets.favorites || []); + + container.innerHTML = ` +
+ + + + +
+ ${CATEGORIES.map(cat => ` + + `).join('')} +
+ + + ${[...favoriteIds].length > 0 ? ` +
+
★ Favorites
+ ${[...favoriteIds].map(id => { + const ind = available.find(a => { + return a.type === id || (activeIndicators.find(ai => ai.id === id)?.type === ''); + }); + if (!ind) return ''; + return renderIndicatorItem(ind, true); + }).join('')} +
+ ` : ''} + + + ${activeIndicators.length > 0 ? ` +
+
+ ${activeIndicators.length} Active + ${activeIndicators.length > 0 ? `` : ''} +
+ ${activeIndicators.slice().reverse().map(ind => renderActiveIndicator(ind)).join('')} +
+ ` : ''} + + + ${catalog.length > 0 ? ` +
+
Available Indicators
+ ${catalog.map(ind => renderIndicatorItem(ind, false)).join('')} +
+ ` : ` +
+ No indicators found +
+ `} +
+ `; + + // Only setup event listeners once + if (!listenersAttached) { + setupEventListeners(); + listenersAttached = true; + } +} + +function renderIndicatorItem(indicator, isFavorite) { + const colorDots = ''; + + return ` +
+
+ ${indicator.name} + ${indicator.description || ''} +
+
+ + ${isFavorite ? '' : ` + + `} +
+
+ `; +} + +function renderActiveIndicator(indicator) { + const isExpanded = configuringId === indicator.id; + const meta = getIndicatorMeta(indicator); + const label = getIndicatorLabel(indicator); + const isFavorite = userPresets.favorites?.includes(indicator.type) || false; + const showPresets = meta.name && function() { + const hasPresets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : []; + if (!hasPresets || hasPresets.length === 0) return ''; + return `
+ +
`; + }(); + + return ` +
+
+
⋮⋮
+ + ${label} + ${showPresets} + + +
+ + ${isExpanded ? ` +
+ ${typeof renderIndicatorConfig === 'function' ? renderIndicatorConfig(indicator, meta) : ''} +
+ ` : ''} +
+ `; +} + +function renderPresetIndicatorIndicator(meta, indicator) { + const hasPresets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : []; + if (!hasPresets || hasPresets.length === 0) return ''; + + return ``; +} + +function renderIndicatorConfig(indicator, meta) { + const plotGroups = groupPlotsByColor(meta?.plots || []); + + return ` +
+ +
+
Visual Settings
+ ${plotGroups.map(group => { + const firstIdx = group.indices[0]; + const color = indicator.params[`_color_${firstIdx}`] || meta.plots[firstIdx]?.color || getDefaultColor(activeIndicators.indexOf(indicator)); + return ` +
+ +
+ + +
+
+ `.trim() + ''; + }).join('')} + + ${indicator.type !== 'rsi' ? ` +
+ + +
+ +
+ + + ${indicator.params._lineWidth || 2} +
+ ` : ''} +
+ + ${meta?.inputs && meta.inputs.length > 0 ? ` +
+
Parameters
+ ${meta.inputs.map(input => ` +
+ + ${input.type === 'select' ? + `` : + `` + } +
+ `).join('')} +
+ ` : ''} +
+ + ${meta?.inputs && meta.inputs.length > 0 ? ` +
+
Parameters
+ ${meta.inputs.map(input => ` +${console.log("[DEBUG] Input:", input.name, "value:", indicator.params[input.name])}` +
+ + ${input.type === 'select' ? + `` : + `` + } +
+ `).join('')} +
+ ` : ''} + +
+
+ Presets + +
+ ${typeof renderIndicatorPresets === 'function' ? renderIndicatorPresets(indicator, meta) : ''} +
+ +
+ + +
+ + `; +} + +function renderIndicatorPresets(indicator, meta) { + const presets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : []; + + return presets.length > 0 ? ` +
+ ${presets.map(p => { + const isApplied = meta.inputs.every(input => + (indicator.params[input.name] === (preset.values?.[input.name] ?? input.default)) + ); + + return ` +
+ ${preset.name} + +
+ `; + }).join('')} +
+ ` : '
No saved presets
'; +} + +// Event listeners +function setupEventListeners() { + const container = document.getElementById('indicatorPanel'); + if (!container) return; + + console.log('[IndicatorPanel] Setting up event listeners...'); + + // Single event delegation handler for add button + container.addEventListener('click', (e) => { + const addBtn = e.target.closest('.indicator-btn.add'); + if (addBtn) { + e.stopPropagation(); + const type = addBtn.dataset.type; + if (type && window.addIndicator) { + console.log('[IndicatorPanel] Add button clicked for type:', type); + window.addIndicator(type); + } + return; + } + + // Expand/collapse button + const expandBtn = e.target.closest('.indicator-btn.expand'); + if (expandBtn) { + e.stopPropagation(); + const id = expandBtn.dataset.id; + if (id && window.toggleIndicatorExpand) { + window.toggleIndicatorExpand(id); + } + return; + } + + // Remove button + const removeBtn = e.target.closest('.indicator-btn.remove'); + if (removeBtn) { + e.stopPropagation(); + const id = removeBtn.dataset.id; + if (id && window.removeIndicatorById) { + window.removeIndicatorById(id); + } + return; + } + + // Favorite button + const favoriteBtn = e.target.closest('.indicator-btn.favorite'); + if (favoriteBtn) { + e.stopPropagation(); + const type = favoriteBtn.dataset.type; + if (type && window.toggleFavorite) { + window.toggleFavorite(type); + } + return; + } + }); + + // Search input + const searchInput = document.getElementById('indicatorSearch'); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + searchQuery = e.target.value; + renderIndicatorPanel(); + }); + } + + // Search clear button + const searchClear = container.querySelector('.search-clear'); + if (searchClear) { + searchClear.addEventListener('click', (e) => { + searchQuery = ''; + renderIndicatorPanel(); + }); + } + + // Category tabs + document.querySelectorAll('.category-tab').forEach(tab => { + tab.addEventListener('click', (e) => { + selectedCategory = tab.dataset.category; + renderIndicatorPanel(); + }); + }); + + // Clear all button + const clearAllBtn = container.querySelector('.clear-all'); + if (clearAllBtn) { + clearAllBtn.addEventListener('click', () => { + window.clearAllIndicators(); + }); + } + + console.log('[IndicatorPanel] Event listeners setup complete'); +} + +// Actions +window.toggleIndicatorExpand = function(id) { + configuringId = configuringId === id ? null : id; + renderIndicatorPanel(); +}; + +window.clearSearch = function() { + searchQuery = ''; + renderIndicatorPanel(); +}; + +window.updateIndicatorColor = function(id, index, color) { + const indicator = activeIndicators.find(a => a.id === id); + if (!indicator) return; + + indicator.params[`_color_${index}`] = color; + drawIndicatorsOnChart(); +}; + +window.updateIndicatorSetting = function(id, key, value) { + const indicator = activeIndicators.find(a => a.id === id); + if (!indicator) return; + + indicator.params[key] = value; + drawIndicatorsOnChart(); +}; + +window.clearAllIndicators = function() { + activeIndicators.forEach(ind => { + ind.series?.forEach(s => { + try { window.dashboard?.chart?.removeSeries(s); } catch(e) {} + }); + }); + activeIndicators = []; + configuringId = null; +renderIndicatorPanel(); + drawIndicatorsOnChart(); +} + +function removeIndicatorById(id) { + const idx = activeIndicators.findIndex(a => a.id === id); + if (idx < 0) return; + + activeIndicators[idx].series?.forEach(s => { + try { window.dashboard?.chart?.removeSeries(s); } catch(e) {} + }); + + activeIndicators.splice(idx, 1); + +if (configuringId === id) { + configuringId = null; + } + + renderIndicatorPanel(); + drawIndicatorsOnChart(); +} + +// Presets +function getPresetsForIndicator(indicatorName) { + if (!userPresets || !userPresets.presets) return []; + return userPresets.presets.filter(p => p.indicatorName === indicatorName); +} + +window.savePreset = function(id) { + const indicator = activeIndicators.find(a => a.id === id); + if (!indicator) return; + + const presetName = prompt('Enter preset name:'); + if (!presetName) return; + + const IndicatorClass = IR?.[indicator.type]; + if (!IndicatorClass) return; + + const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name }); + const meta = instance.getMetadata(); + + const preset = { + id: `preset_${Date.now()}`, + name: presetName, + indicatorName: meta.name, + values: {} + }; + + meta.inputs.forEach(input => { + preset.values[input.name] = indicator.params[input.name]; + }); + + if (!userPresets.presets) userPresets.presets = []; + userPresets.presets.push(preset); + saveUserPresets(); + renderIndicatorPanel(); + + alert(`Preset "${presetName}" saved!`); +}; + +window.applyPreset = function(id, presetId) { + const allPresets = (userPresets?.presets || []).filter(p => typeof p === 'object' && p.id); + const preset = allPresets.find(p => p.id === presetId); + if (!preset) return; + + const indicator = activeIndicators.find(a => a.id === id); + if (!indicator) return; + + Object.keys(preset.values).forEach(key => { + indicator.params[key] = preset.values[key]; + }); + + renderIndicatorPanel(); + drawIndicatorsOnChart(); +}; + +window.deletePreset = function(presetId) { + if (!confirm('Delete this preset?')) return; + + if (userPresets?.presets) { + userPresets.presets = userPresets.presets.filter(p => p.id !== presetId); + saveUserPresets(); + renderIndicatorPanel(); + } +}; + +window.showPresets = function(indicatorName) { + const presets = getPresetsForIndicator(indicatorName); + if (presets.length === 0) { + alert('No saved presets for this indicator'); + return; + } + + const menu = window.open('', '_blank', 'width=400,height=500'); + + let htmlContent = + 'Presets - ' + indicatorName + '' + + '

' + indicatorName + ' Presets

'; + + presets.forEach(p => { + htmlContent += '
' + p.name + '
'; + }); + + htmlContent += ''; + + menu.document.write(htmlContent); +}; + +window.applyPresetFromWindow = function(presetId) { + const indicator = activeIndicators.find(a => a.id === configuringId); + if (!indicator) return; + applyPreset(indicator.id, presetId); +}; + +function addIndicator(type) { + const IndicatorClass = IR?.[type]; + if (!IndicatorClass) return; + + const id = `${type}_${nextInstanceId++}`; + const instance = new IndicatorClass({ type, params: {}, name: '' }); + const metadata = instance.getMetadata(); + + const params = { + _lineType: 'solid', + _lineWidth: 2 + }; + metadata.plots.forEach((plot, idx) => { + params[`_color_${idx}`] = plot.color || getDefaultColor(activeIndicators.length + idx); + }); + metadata.inputs.forEach(input => { + params[input.name] = input.default; + }); + + activeIndicators.push({ + id, + type, + name: metadata.name, + params, + plots: metadata.plots, + series: [], + visible: true + }); + // Don't set configuringId so indicators are NOT expanded by default + renderIndicatorPanel(); + drawIndicatorsOnChart(); +}; + +function saveUserPresets() { + localStorage.setItem('indicator_presets', JSON.stringify(userPresets)); +} + +function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) { + const results = instance.calculate(candles); + indicator.series = []; + + const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid; + const lineWidth = indicator.params._lineWidth || 2; + + const firstNonNull = results?.find(r => r !== null && r !== undefined); + const isObjectResult = firstNonNull && typeof firstNonNull === 'object'; + + meta.plots.forEach((plot, plotIdx) => { + if (isObjectResult) { + const hasData = results.some(r => r && r[plot.id] !== undefined && r[plot.id] !== null); + if (!hasData) return; + } + + const plotColor = indicator.params[`_color_${plotIdx}`] || plot.color || '#2962ff'; + + const data = []; + for (let i = 0; i < candles.length; i++) { + let value; + if (isObjectResult) { + value = results[i]?.[plot.id]; + } else { + value = results[i]; + } + + if (value !== null && value !== undefined) { + data.push({ + time: candles[i].time, + value: value + }); + } + } + + if (data.length === 0) return; + + let series; + let plotLineStyle = lineStyle; + if (plot.style === 'dashed') plotLineStyle = LightweightCharts.LineStyle.Dashed; + else if (plot.style === 'dotted') plotLineStyle = LightweightCharts.LineStyle.Dotted; + else if (plot.style === 'solid') plotLineStyle = LightweightCharts.LineStyle.Solid; + + if (plot.type === 'histogram') { + series = window.dashboard.chart.addSeries(LightweightCharts.HistogramSeries, { + color: plotColor, + priceFormat: { type: 'price', precision: 4, minMove: 0.0001 }, + priceLineVisible: false, + lastValueVisible: false + }, paneIndex); + } else if (plot.type === 'baseline') { + series = window.dashboard.chart.addSeries(LightweightCharts.BaselineSeries, { + baseValue: { type: 'price', price: plot.baseValue || 0 }, + topLineColor: plot.topLineColor || plotColor, + topFillColor1: plot.topFillColor1 || plotColor, + topFillColor2: '#00000000', + bottomFillColor1: '#00000000', + bottomColor: plot.bottomColor || '#00000000', + lineWidth: plot.width || indicator.params._lineWidth || lineWidth, + lineStyle: plotLineStyle, + title: plot.title || '', + priceLineVisible: false, + lastValueVisible: plot.lastValueVisible !== false + }, paneIndex); + } else { + series = window.dashboard.chart.addSeries(LightweightCharts.LineSeries, { + color: plotColor, + lineWidth: plot.width || indicator.params._lineWidth || lineWidth, + lineStyle: plotLineStyle, + title: plot.title || '', + priceLineVisible: false, + lastValueVisible: plot.lastValueVisible !== false + }, paneIndex); + } + + series.setData(data); + indicator.series.push(series); + + // Create horizontal band lines for RSI + if (meta.name === 'RSI' && indicator.series.length > 0) { + const mainSeries = indicator.series[0]; + const overbought = indicator.params.overbought || 70; + const oversold = indicator.params.oversold || 30; + + // Remove existing price lines first + while (indicator.bands && indicator.bands.length > 0) { + try { + indicator.bands.pop(); + } catch(e) {} + } + indicator.bands = indicator.bands || []; + + // Create overbought band line + indicator.bands.push(mainSeries.createPriceLine({ + price: overbought, + color: '#787B86', + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Dashed, + axisLabelVisible: false, + title: '' + })); + + // Create oversold band line + indicator.bands.push(mainSeries.createPriceLine({ + price: oversold, + color: '#787B86', + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Dashed, + axisLabelVisible: false, + title: '' + })); + } + }); +} + +// Chart drawing +export function drawIndicatorsOnChart() { + if (!window.dashboard || !window.dashboard.chart) return; + + activeIndicators.forEach(ind => { + ind.series?.forEach(s => { + try { window.dashboard.chart.removeSeries(s); } catch(e) {} + }); + }); + + const candles = window.dashboard.allData.get(window.dashboard.currentInterval); + if (!candles || candles.length === 0) return; + + const lineStyleMap = { + 'solid': LightweightCharts.LineStyle.Solid, + 'dotted': LightweightCharts.LineStyle.Dotted, + 'dashed': LightweightCharts.LineStyle.Dashed + }; + + indicatorPanes.clear(); + nextPaneIndex = 1; + + const overlayIndicators = []; + const paneIndicators = []; + + activeIndicators.forEach(ind => { + const IndicatorClass = IR?.[ind.type]; + if (!IndicatorClass) return; + + const instance = new IndicatorClass({ type: ind.type, params: ind.params, name: ind.name }); + const meta = instance.getMetadata(); + + if (meta.displayMode === 'pane') { + paneIndicators.push({ indicator: ind, meta, instance }); + } else { + overlayIndicators.push({ indicator: ind, meta, instance }); + } + }); + + const totalPanes = 1 + paneIndicators.length; + const mainPaneHeight = paneIndicators.length > 0 ? 60 : 100; + const paneHeight = paneIndicators.length > 0 ? Math.floor(40 / paneIndicators.length) : 0; + + window.dashboard.chart.panes()[0]?.setHeight(mainPaneHeight); + + overlayIndicators.forEach(({ indicator, meta, instance }) => { + if (indicator.visible === false) { + indicator.series = []; + return; + } + + renderIndicatorOnPane(indicator, meta, instance, candles, 0, lineStyleMap); + }); + + paneIndicators.forEach(({ indicator, meta, instance }, idx) => { + if (indicator.visible === false) { + indicator.series = []; + return; + } + + const paneIndex = nextPaneIndex++; + indicatorPanes.set(indicator.id, paneIndex); + + renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap); + + const pane = window.dashboard.chart.panes()[paneIndex]; + if (pane) { + pane.setHeight(paneHeight); + } + }); +} + +// Export functions for module access +export { addIndicator, removeIndicatorById }; + +// Legacy compatibility functions +window.renderIndicatorList = renderIndicatorPanel; +window.toggleIndicator = addIndicator; +window.showIndicatorConfig = function(id) { + const ind = activeIndicators.find(a => a.id === id); + if (ind) configuringId = id; + renderIndicatorPanel(); +}; +window.applyIndicatorConfig = function() { +// No-op - config is applied immediately +}; \ No newline at end of file diff --git a/js/ui/indicators-panel.js b/js/ui/indicators-panel.js new file mode 100644 index 0000000..2937588 --- /dev/null +++ b/js/ui/indicators-panel.js @@ -0,0 +1,703 @@ +import { getAvailableIndicators, IndicatorRegistry as IR } from '../indicators/index.js'; + +let activeIndicators = []; +let configuringId = null; +let previewingType = null; // type being previewed (not yet added) +let nextInstanceId = 1; + +const DEFAULT_COLORS = ['#2962ff', '#26a69a', '#ef5350', '#ff9800', '#9c27b0', '#00bcd4', '#ffeb3b', '#e91e63']; +const LINE_TYPES = ['solid', 'dotted', 'dashed']; + +function getDefaultColor(index) { + return DEFAULT_COLORS[index % DEFAULT_COLORS.length]; +} + +function getPlotGroupName(plotId) { + if (plotId.toLowerCase().includes('fast')) return 'Fast'; + if (plotId.toLowerCase().includes('slow')) return 'Slow'; + if (plotId.toLowerCase().includes('upper')) return 'Upper'; + if (plotId.toLowerCase().includes('lower')) return 'Lower'; + if (plotId.toLowerCase().includes('middle') || plotId.toLowerCase().includes('basis')) return 'Middle'; + if (plotId.toLowerCase().includes('signal')) return 'Signal'; + if (plotId.toLowerCase().includes('histogram')) return 'Histogram'; + if (plotId.toLowerCase().includes('k')) return '%K'; + if (plotId.toLowerCase().includes('d')) return '%D'; + return plotId; +} + +function groupPlotsByColor(plots) { + const groups = {}; + plots.forEach((plot, idx) => { + const groupName = getPlotGroupName(plot.id); + if (!groups[groupName]) { + groups[groupName] = { name: groupName, indices: [], plots: [] }; + } + groups[groupName].indices.push(idx); + groups[groupName].plots.push(plot); + }); + return Object.values(groups); +} + +/** Generate a short label for an active indicator showing its key params */ +function getIndicatorLabel(indicator) { + const meta = getIndicatorMeta(indicator); + if (!meta) return indicator.name; + + const paramParts = meta.inputs.map(input => { + const val = indicator.params[input.name]; + if (val !== undefined && val !== input.default) return val; + if (val !== undefined) return val; + return null; + }).filter(v => v !== null); + + if (paramParts.length > 0) { + return `${indicator.name} (${paramParts.join(', ')})`; + } + return indicator.name; +} + +function getIndicatorMeta(indicator) { + const IndicatorClass = IR?.[indicator.type]; + if (!IndicatorClass) return null; + const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name }); + return instance.getMetadata(); +} + +export function getActiveIndicators() { + return activeIndicators; +} + +export function setActiveIndicators(indicators) { + activeIndicators = indicators; +} + +/** + * Render the indicator catalog (available indicators) and active list. + * Catalog items are added via double-click (multiple instances allowed). + */ +export function renderIndicatorList() { + const container = document.getElementById('indicatorList'); + if (!container) return; + + const available = getAvailableIndicators(); + + container.innerHTML = ` +
+ ${available.map(ind => ` +
+ ${ind.name} + + +
+ `).join('')} +
+ ${activeIndicators.length > 0 ? ` +
Active
+
+ ${activeIndicators.map(ind => { + const isConfiguring = ind.id === configuringId; + const plotGroups = groupPlotsByColor(ind.plots || []); + const colorDots = plotGroups.map(group => { + const firstIdx = group.indices[0]; + const color = ind.params[`_color_${firstIdx}`] || '#2962ff'; + return ``; + }).join(''); + const label = getIndicatorLabel(ind); + + return ` +
+ + ${ind.visible !== false ? '👁' : '👁‍🗨'} + + ${label} + ${colorDots} + + +
+ `; + }).join('')} +
+ ` : ''} + `; + + // Bind events via delegation + container.querySelectorAll('.indicator-catalog-item').forEach(el => { + el.addEventListener('click', () => previewIndicator(el.dataset.type)); + el.addEventListener('dblclick', () => addIndicator(el.dataset.type)); + }); + container.querySelectorAll('.indicator-catalog-add').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + addIndicator(el.dataset.type); + }); + }); + container.querySelectorAll('.indicator-active-name').forEach(el => { + el.addEventListener('click', () => selectIndicatorConfig(el.dataset.id)); + }); + container.querySelectorAll('.indicator-config-btn').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + selectIndicatorConfig(el.dataset.id); + }); + }); + container.querySelectorAll('.indicator-remove-btn').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + removeIndicatorById(el.dataset.id); + }); + }); + container.querySelectorAll('.indicator-active-eye').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + toggleVisibility(el.dataset.id); + }); + }); + + updateConfigPanel(); + updateChartLegend(); +} + +function updateConfigPanel() { + const configPanel = document.getElementById('indicatorConfigPanel'); + const configButtons = document.getElementById('configButtons'); + if (!configPanel) return; + + configPanel.style.display = 'block'; + + // Active indicator config takes priority over preview + const indicator = configuringId ? activeIndicators.find(a => a.id === configuringId) : null; + + if (indicator) { + renderIndicatorConfig(indicator); + if (configButtons) configButtons.style.display = 'flex'; + } else if (previewingType) { + renderPreviewConfig(previewingType); + if (configButtons) configButtons.style.display = 'none'; + } else { + const container = document.getElementById('configForm'); + if (container) { + container.innerHTML = '
Click an indicator to preview its settings
'; + } + if (configButtons) configButtons.style.display = 'none'; + } +} + +/** Single-click: preview config for a catalog indicator type (read-only) */ +function previewIndicator(type) { + configuringId = null; + previewingType = previewingType === type ? null : type; + renderIndicatorList(); +} + +/** Render a read-only preview of an indicator's default config */ +function renderPreviewConfig(type) { + const container = document.getElementById('configForm'); + if (!container) return; + + const IndicatorClass = IR?.[type]; + if (!IndicatorClass) return; + + const instance = new IndicatorClass({ type, params: {}, name: '' }); + const meta = instance.getMetadata(); + + container.innerHTML = ` +
${meta.name}
+
${meta.description || ''}
+ + ${meta.inputs.map(input => ` +
+ + ${input.type === 'select' ? + `` : + `` + } +
+ `).join('')} + +
Double-click to add to chart
+ `; +} + +/** Add a new instance of an indicator type */ +export function addIndicator(type) { + const IndicatorClass = IR?.[type]; + if (!IndicatorClass) return; + + previewingType = null; + const id = `${type}_${nextInstanceId++}`; + const instance = new IndicatorClass({ type, params: {}, name: '' }); + const metadata = instance.getMetadata(); + + const params = { + _lineType: 'solid', + _lineWidth: 1 + }; + + // Set Hurst-specific defaults + if (type === 'hurst') { + params.timeframe = 'chart'; + params.markerBuyShape = 'custom'; + params.markerSellShape = 'custom'; + params.markerBuyColor = '#9e9e9e'; + params.markerSellColor = '#9e9e9e'; + params.markerBuyCustom = '▲'; + params.markerSellCustom = '▼'; + } + + metadata.plots.forEach((plot, idx) => { + params[`_color_${idx}`] = plot.color || getDefaultColor(activeIndicators.length + idx); + }); + metadata.inputs.forEach(input => { + params[input.name] = input.default; + }); + + activeIndicators.push({ + id, + type, + name: metadata.name, + params, + plots: metadata.plots, + series: [], + visible: true + }); + + configuringId = id; + + renderIndicatorList(); + drawIndicatorsOnChart(); +} + +function selectIndicatorConfig(id) { + previewingType = null; + if (configuringId === id) { + configuringId = null; + } else { + configuringId = id; + } + renderIndicatorList(); +} + +function toggleVisibility(id) { + const indicator = activeIndicators.find(a => a.id === id); + if (!indicator) return; + + indicator.visible = indicator.visible === false ? true : false; + + // Show/hide all series for this indicator + indicator.series?.forEach(s => { + try { + s.applyOptions({ visible: indicator.visible }); + } catch(e) {} + }); + + renderIndicatorList(); +} + +export function renderIndicatorConfig(indicator) { + const container = document.getElementById('configForm'); + if (!container || !indicator) return; + + const IndicatorClass = IR?.[indicator.type]; + if (!IndicatorClass) { + container.innerHTML = '
Error loading indicator
'; + return; + } + + const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name }); + const meta = instance.getMetadata(); + + const plotGroups = groupPlotsByColor(meta.plots); + + const colorInputs = plotGroups.map(group => { + const firstIdx = group.indices[0]; + const color = indicator.params[`_color_${firstIdx}`] || meta.plots[firstIdx].color || '#2962ff'; + return ` +
+ + +
+ `; + }).join(''); + + container.innerHTML = ` +
${getIndicatorLabel(indicator)}
+ + ${colorInputs} + +
+ + +
+ +
+ + +
+ + ${meta.inputs.map(input => ` +
+ + ${input.type === 'select' ? + `` : + `` + } +
+ `).join('')} + `; +} + +export function applyIndicatorConfig() { + const indicator = configuringId ? activeIndicators.find(a => a.id === configuringId) : null; + if (!indicator) return; + + const IndicatorClass = IR?.[indicator.type]; + if (!IndicatorClass) return; + + const instance = new IndicatorClass({ type: indicator.type, params: {}, name: indicator.name }); + const meta = instance.getMetadata(); + + const plotGroups = groupPlotsByColor(meta.plots); + plotGroups.forEach(group => { + const firstIdx = group.indices[0]; + const colorEl = document.getElementById(`config__color_${firstIdx}`); + if (colorEl) { + const color = colorEl.value; + group.indices.forEach(idx => { + indicator.params[`_color_${idx}`] = color; + }); + } + }); + + const lineTypeEl = document.getElementById('config__lineType'); + const lineWidthEl = document.getElementById('config__lineWidth'); + + if (lineTypeEl) indicator.params._lineType = lineTypeEl.value; + if (lineWidthEl) indicator.params._lineWidth = parseInt(lineWidthEl.value); + + meta.inputs.forEach(input => { + const el = document.getElementById(`config_${input.name}`); + if (el) { + indicator.params[input.name] = input.type === 'select' ? el.value : parseFloat(el.value); + } + }); + + renderIndicatorList(); + drawIndicatorsOnChart(); +} + +export function removeIndicator() { + if (!configuringId) return; + removeIndicatorById(configuringId); +} + +export function removeIndicatorById(id) { + const idx = activeIndicators.findIndex(a => a.id === id); + if (idx < 0) return; + + activeIndicators[idx].series?.forEach(s => { + try { window.dashboard?.chart?.removeSeries(s); } catch(e) {} + }); + + activeIndicators.splice(idx, 1); + + if (configuringId === id) { + configuringId = null; + } + + renderIndicatorList(); + drawIndicatorsOnChart(); +} + +export function removeIndicatorByIndex(index) { + if (index < 0 || index >= activeIndicators.length) return; + removeIndicatorById(activeIndicators[index].id); +} + +let indicatorPanes = new Map(); +let nextPaneIndex = 1; + +export function drawIndicatorsOnChart() { + if (!window.dashboard || !window.dashboard.chart) return; + + activeIndicators.forEach(ind => { + ind.series?.forEach(s => { + try { window.dashboard.chart.removeSeries(s); } catch(e) {} + }); + }); + + const candles = window.dashboard.allData.get(window.dashboard.currentInterval); + if (!candles || candles.length === 0) return; + + const lineStyleMap = { 'solid': LightweightCharts.LineStyle.Solid, 'dotted': LightweightCharts.LineStyle.Dotted, 'dashed': LightweightCharts.LineStyle.Dashed }; + + indicatorPanes.clear(); + nextPaneIndex = 1; + + const overlayIndicators = []; + const paneIndicators = []; + + activeIndicators.forEach(ind => { + const IndicatorClass = IR?.[ind.type]; + if (!IndicatorClass) return; + + const instance = new IndicatorClass({ type: ind.type, params: ind.params, name: ind.name }); + const meta = instance.getMetadata(); + + if (meta.displayMode === 'pane') { + paneIndicators.push({ indicator: ind, meta, instance }); + } else { + overlayIndicators.push({ indicator: ind, meta, instance }); + } + }); + + const totalPanes = 1 + paneIndicators.length; + const mainPaneHeight = paneIndicators.length > 0 ? 60 : 100; + const paneHeight = paneIndicators.length > 0 ? Math.floor(40 / paneIndicators.length) : 0; + + window.dashboard.chart.panes()[0]?.setHeight(mainPaneHeight); + + overlayIndicators.forEach(({ indicator, meta, instance }) => { + if (indicator.visible === false) { + indicator.series = []; + return; + } + + renderIndicatorOnPane(indicator, meta, instance, candles, 0, lineStyleMap); + }); + + paneIndicators.forEach(({ indicator, meta, instance }, idx) => { + if (indicator.visible === false) { + indicator.series = []; + return; + } + + const paneIndex = nextPaneIndex++; + indicatorPanes.set(indicator.id, paneIndex); + + renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap); + + const pane = window.dashboard.chart.panes()[paneIndex]; + if (pane) { + pane.setHeight(paneHeight); + } + }); + + updateChartLegend(); +} + +function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) { + let results = instance.calculate(candles); + if (!results || !Array.isArray(results)) { + console.error(`[renderIndicatorOnPane] ${indicator.name}: Failed to get valid results (got ${typeof results})`); + return; + } + indicator.series = []; + + const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid; + const lineWidth = indicator.params._lineWidth || 1; + + const firstNonNull = Array.isArray(results) ? results.find(r => r !== null && r !== undefined) : null; + const isObjectResult = firstNonNull && typeof firstNonNull === 'object'; + + meta.plots.forEach((plot, plotIdx) => { + if (isObjectResult) { + // Find if this specific plot has any non-null data across all results + const hasData = results.some(r => r && r[plot.id] !== undefined && r[plot.id] !== null); + if (!hasData) return; + } + + // Skip hidden plots + if (plot.visible === false) return; + + const plotColor = indicator.params[`_color_${plotIdx}`] || plot.color || '#2962ff'; + + const data = []; + for (let i = 0; i < candles.length; i++) { + let value; + if (isObjectResult) { + value = results[i]?.[plot.id]; + } else { + value = results[i]; + } + + if (value !== null && value !== undefined) { + data.push({ + time: candles[i].time, + value: value + }); + } + } + + if (data.length === 0) return; + + let series; + + // Determine line style for this specific plot + let plotLineStyle = lineStyle; + if (plot.style === 'dashed') plotLineStyle = LightweightCharts.LineStyle.Dashed; + else if (plot.style === 'dotted') plotLineStyle = LightweightCharts.LineStyle.Dotted; + else if (plot.style === 'solid') plotLineStyle = LightweightCharts.LineStyle.Solid; + + if (plot.type === 'histogram') { + series = window.dashboard.chart.addSeries(LightweightCharts.HistogramSeries, { + color: plotColor, + priceFormat: { + type: 'price', + precision: 4, + minMove: 0.0001 + }, + priceLineVisible: false, + lastValueVisible: false + }, paneIndex); + } else if (plot.type === 'baseline') { + series = window.dashboard.chart.addSeries(LightweightCharts.BaselineSeries, { + baseValue: { type: 'price', price: plot.baseValue || 0 }, + topLineColor: plot.topLineColor || plotColor, + topFillColor1: plot.topFillColor1 || plotColor, + topFillColor2: plot.topFillColor2 || '#00000000', + bottomFillColor1: plot.bottomFillColor1 || '#00000000', + bottomColor: plot.bottomColor || '#00000000', + lineWidth: plot.width !== undefined ? plot.width : lineWidth, + lineStyle: plotLineStyle, + title: plot.title || '', + priceLineVisible: false, + lastValueVisible: plot.lastValueVisible !== false + }, paneIndex); + } else { + series = window.dashboard.chart.addSeries(LightweightCharts.LineSeries, { + color: plotColor, + lineWidth: plot.width !== undefined ? plot.width : lineWidth, + lineStyle: plotLineStyle, + title: plot.title || '', + priceLineVisible: false, + lastValueVisible: plot.lastValueVisible !== false + }, paneIndex); + } + + series.setData(data); + series.plotId = plot.id; + + // Skip hidden plots (visible: false) + if (plot.visible === false) { + series.applyOptions({ visible: false }); + } + + indicator.series.push(series); + }); + + // Render gradient zones if available + if (meta.gradientZones && indicator.series.length > 0) { + // Find the main series to attach zones to + let baseSeries = indicator.series[0]; + + meta.gradientZones.forEach(zone => { + if (zone.from === undefined || zone.to === undefined) return; + + // We use createPriceLine on the series for horizontal bands with custom colors + baseSeries.createPriceLine({ + price: zone.from, + color: zone.color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[^)]+\)/, 'rgb($1, $2, $3)'), + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Solid, + axisLabelVisible: false, + title: zone.label || '', + }); + + if (zone.to !== zone.from) { + baseSeries.createPriceLine({ + price: zone.to, + color: zone.color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[^)]+\)/, 'rgb($1, $2, $3)'), + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Solid, + axisLabelVisible: false, + title: '', + }); + } + }); + } +} + +/** Update the TradingView-style legend overlay on the chart */ +export function updateChartLegend() { + let legend = document.getElementById('chartIndicatorLegend'); + if (!legend) { + const chartWrapper = document.getElementById('chartWrapper'); + if (!chartWrapper) return; + legend = document.createElement('div'); + legend.id = 'chartIndicatorLegend'; + legend.className = 'chart-indicator-legend'; + chartWrapper.appendChild(legend); + } + + if (activeIndicators.length === 0) { + legend.innerHTML = ''; + legend.style.display = 'none'; + return; + } + + legend.style.display = 'flex'; + legend.innerHTML = activeIndicators.map(ind => { + const label = getIndicatorLabel(ind); + const plotGroups = groupPlotsByColor(ind.plots || []); + const firstColor = ind.params['_color_0'] || '#2962ff'; + const dimmed = ind.visible === false; + + return ` +
+ + ${label} + × +
+ `; + }).join(''); + + // Bind legend events + legend.querySelectorAll('.legend-item').forEach(el => { + el.addEventListener('click', (e) => { + if (e.target.classList.contains('legend-close')) return; + selectIndicatorConfig(el.dataset.id); + renderIndicatorList(); + }); + }); + legend.querySelectorAll('.legend-close').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + removeIndicatorById(el.dataset.id); + }); + }); +} + +// Legacy compat: toggleIndicator still works for external callers +export function toggleIndicator(type) { + addIndicator(type); +} + +export function showIndicatorConfig(index) { + if (index >= 0 && index < activeIndicators.length) { + selectIndicatorConfig(activeIndicators[index].id); + } +} + +export function showIndicatorConfigByType(type) { + const ind = activeIndicators.find(a => a.type === type); + if (ind) { + selectIndicatorConfig(ind.id); + } +} + +window.addIndicator = addIndicator; +window.toggleIndicator = toggleIndicator; +window.showIndicatorConfig = showIndicatorConfig; +window.applyIndicatorConfig = applyIndicatorConfig; +window.removeIndicator = removeIndicator; +window.removeIndicatorById = removeIndicatorById; +window.removeIndicatorByIndex = removeIndicatorByIndex; +window.drawIndicatorsOnChart = drawIndicatorsOnChart; diff --git a/js/ui/markers-plugin.js b/js/ui/markers-plugin.js new file mode 100644 index 0000000..8270adc --- /dev/null +++ b/js/ui/markers-plugin.js @@ -0,0 +1,117 @@ +export class SeriesMarkersPrimitive { + constructor(markers) { + this._markers = markers || []; + this._paneViews = [new MarkersPaneView(this)]; + } + + setMarkers(markers) { + this._markers = markers; + if (this._requestUpdate) { + this._requestUpdate(); + } + } + + attached(param) { + this._chart = param.chart; + this._series = param.series; + this._requestUpdate = param.requestUpdate; + this._requestUpdate(); + } + + detached() { + this._chart = undefined; + this._series = undefined; + this._requestUpdate = undefined; + } + + updateAllViews() {} + + paneViews() { + return this._paneViews; + } +} + +class MarkersPaneView { + constructor(source) { + this._source = source; + } + + renderer() { + return new MarkersRenderer(this._source); + } +} + +class MarkersRenderer { + constructor(source) { + this._source = source; + } + + draw(target) { + if (!this._source._chart || !this._source._series) return; + + // Lightweight Charts v5 wraps context + const ctx = target.context; + const series = this._source._series; + const chart = this._source._chart; + const markers = this._source._markers; + + ctx.save(); + + // Ensure markers are sorted by time (usually already done) + for (const marker of markers) { + const timeCoordinate = chart.timeScale().timeToCoordinate(marker.time); + if (timeCoordinate === null) continue; + + // To position above or below bar, we need the candle data or we use the marker.value if provided + // For true aboveBar/belowBar without candle data, we might just use series.priceToCoordinate on marker.value + let price = marker.value; + // Fallbacks if no value provided (which our calculator does provide) + if (!price) continue; + + const priceCoordinate = series.priceToCoordinate(price); + if (priceCoordinate === null) continue; + + const x = timeCoordinate; + const size = 5; + const margin = 12; // Gap between price and marker + const isAbove = marker.position === 'aboveBar'; + const y = isAbove ? priceCoordinate - margin : priceCoordinate + margin; + + ctx.fillStyle = marker.color || '#26a69a'; + ctx.beginPath(); + + if (marker.shape === 'arrowUp' || (!marker.shape && !isAbove)) { + ctx.moveTo(x, y - size); + ctx.lineTo(x - size, y + size); + ctx.lineTo(x + size, y + size); + } else if (marker.shape === 'arrowDown' || (!marker.shape && isAbove)) { + ctx.moveTo(x, y + size); + ctx.lineTo(x - size, y - size); + ctx.lineTo(x + size, y - size); + } else if (marker.shape === 'circle') { + ctx.arc(x, y, size, 0, Math.PI * 2); + } else if (marker.shape === 'square') { + ctx.rect(x - size, y - size, size * 2, size * 2); + } else if (marker.shape === 'custom' && marker.text) { + ctx.font = '12px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(marker.text, x, y); + continue; + } else { + // Default triangle + if (isAbove) { + ctx.moveTo(x, y + size); + ctx.lineTo(x - size, y - size); + ctx.lineTo(x + size, y - size); + } else { + ctx.moveTo(x, y - size); + ctx.lineTo(x - size, y + size); + ctx.lineTo(x + size, y + size); + } + } + ctx.fill(); + } + ctx.restore(); + } +} diff --git a/js/ui/sidebar.js b/js/ui/sidebar.js new file mode 100644 index 0000000..c5f2b7c --- /dev/null +++ b/js/ui/sidebar.js @@ -0,0 +1,73 @@ +export function toggleSidebar() { + const sidebar = document.getElementById('rightSidebar'); + sidebar.classList.toggle('collapsed'); + localStorage.setItem('sidebar_collapsed', sidebar.classList.contains('collapsed')); + + // Resize chart after sidebar toggle + setTimeout(() => { + if (window.dashboard && window.dashboard.chart) { + const container = document.getElementById('chart'); + window.dashboard.chart.applyOptions({ + width: container.clientWidth, + height: container.clientHeight + }); + } + }, 350); // Wait for CSS transition +} + +export function restoreSidebarState() { + const collapsed = localStorage.getItem('sidebar_collapsed') !== 'false'; // Default to collapsed + const sidebar = document.getElementById('rightSidebar'); + if (collapsed && sidebar) { + sidebar.classList.add('collapsed'); + } +} + +// Tab Management +let activeTab = 'indicators'; + +export function initSidebarTabs() { + const tabs = document.querySelectorAll('.sidebar-tab'); + + tabs.forEach(tab => { + tab.addEventListener('click', () => { + switchTab(tab.dataset.tab); + }); + }); +} + +export function switchTab(tabId) { + activeTab = tabId; + localStorage.setItem('sidebar_active_tab', tabId); + + document.querySelectorAll('.sidebar-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.tab === tabId); + }); + + document.querySelectorAll('.sidebar-tab-panel').forEach(panel => { + panel.classList.toggle('active', panel.id === `tab-${tabId}`); + }); + + if (tabId === 'indicators') { + setTimeout(() => { + if (window.drawIndicatorsOnChart) { + window.drawIndicatorsOnChart(); + } + }, 50); + } else if (tabId === 'strategy') { + setTimeout(() => { + if (window.renderStrategyPanel) { + window.renderStrategyPanel(); + } + }, 50); + } +} + +export function getActiveTab() { + return activeTab; +} + +export function restoreSidebarTabState() { + const savedTab = localStorage.getItem('sidebar_active_tab') || 'indicators'; + switchTab(savedTab); +} diff --git a/js/ui/signal-markers.js b/js/ui/signal-markers.js new file mode 100644 index 0000000..3474f13 --- /dev/null +++ b/js/ui/signal-markers.js @@ -0,0 +1,231 @@ +import { IndicatorRegistry } from '../indicators/index.js'; + +export function calculateSignalMarkers(candles) { + const activeIndicators = window.getActiveIndicators?.() || []; + const markers = []; + + if (!candles || candles.length < 2) { + return markers; + } + + for (const indicator of activeIndicators) { + if (indicator.params.showMarkers === false || indicator.params.showMarkers === 'false') { + continue; + } + + console.log('[SignalMarkers] Processing indicator:', indicator.type, 'showMarkers:', indicator.params.showMarkers); + + // Use cache if available + let results = indicator.cachedResults; + if (!results || !Array.isArray(results) || results.length !== candles.length) { + const IndicatorClass = IndicatorRegistry[indicator.type]; + if (!IndicatorClass) { + continue; + } + const instance = new IndicatorClass(indicator); + results = instance.calculate(candles); + } + + if (!results || !Array.isArray(results) || results.length === 0) { + continue; + } + + const indicatorMarkers = findCrossoverMarkers(indicator, candles, results); + markers.push(...indicatorMarkers); + } + + markers.sort((a, b) => a.time - b.time); + + return markers; +} + +function findCrossoverMarkers(indicator, candles, results) { + const markers = []; + const overbought = indicator.params?.overbought || 70; + const oversold = indicator.params?.oversold || 30; + const indicatorType = indicator.type; + + const buyColor = indicator.params?.markerBuyColor || '#26a69a'; + const sellColor = indicator.params?.markerSellColor || '#ef5350'; + const buyShape = indicator.params?.markerBuyShape || 'arrowUp'; + const sellShape = indicator.params?.markerSellShape || 'arrowDown'; + const buyCustom = indicator.params?.markerBuyCustom || '◭'; + const sellCustom = indicator.params?.markerSellCustom || '▼'; + + for (let i = 1; i < results.length; i++) { + const candle = candles[i]; + const prevCandle = candles[i - 1]; + const result = results[i]; + const prevResult = results[i - 1]; + + if (!result || !prevResult) continue; + + if (indicatorType === 'rsi' || indicatorType === 'stoch') { + const rsi = result.rsi ?? result; + const prevRsi = prevResult.rsi ?? prevResult; + + if (rsi === undefined || prevRsi === undefined) continue; + + if (prevRsi > overbought && rsi <= overbought) { + markers.push({ + time: candle.time, + position: 'aboveBar', + color: sellColor, + shape: sellShape === 'custom' ? '' : sellShape, + text: sellShape === 'custom' ? sellCustom : '' + }); + } + + if (prevRsi < oversold && rsi >= oversold) { + markers.push({ + time: candle.time, + position: 'belowBar', + color: buyColor, + shape: buyShape === 'custom' ? '' : buyShape, + text: buyShape === 'custom' ? buyCustom : '' + }); + } + } else if (indicatorType === 'macd') { + const macd = result.macd ?? result.MACD; + const signal = result.signal ?? result.signalLine; + const prevMacd = prevResult.macd ?? prevResult.MACD; + const prevSignal = prevResult.signal ?? prevResult.signalLine; + + if (macd === undefined || signal === undefined || prevMacd === undefined || prevSignal === undefined) continue; + + const macdAbovePrev = prevMacd > prevSignal; + const macdAboveNow = macd > signal; + + if (macdAbovePrev && !macdAboveNow) { + markers.push({ + time: candle.time, + position: 'aboveBar', + color: sellColor, + shape: sellShape === 'custom' ? '' : sellShape, + text: sellShape === 'custom' ? sellCustom : '' + }); + } + + if (!macdAbovePrev && macdAboveNow) { + markers.push({ + time: candle.time, + position: 'belowBar', + color: buyColor, + shape: buyShape === 'custom' ? '' : buyShape, + text: buyShape === 'custom' ? buyCustom : '' + }); + } + } else if (indicatorType === 'bb') { + const upper = result.upper ?? result.upperBand; + const lower = result.lower ?? result.lowerBand; + + if (upper === undefined || lower === undefined) continue; + + const priceAboveUpperPrev = prevCandle.close > (prevResult.upper ?? prevResult.upperBand); + const priceAboveUpperNow = candle.close > upper; + + if (priceAboveUpperPrev && !priceAboveUpperNow) { + markers.push({ + time: candle.time, + position: 'aboveBar', + color: sellColor, + shape: sellShape === 'custom' ? '' : sellShape, + text: sellShape === 'custom' ? sellCustom : '' + }); + } + + if (!priceAboveUpperPrev && priceAboveUpperNow) { + markers.push({ + time: candle.time, + position: 'belowBar', + color: buyColor, + shape: buyShape === 'custom' ? '' : buyShape, + text: buyShape === 'custom' ? buyCustom : '' + }); + } + + const priceBelowLowerPrev = prevCandle.close < (prevResult.lower ?? prevResult.lowerBand); + const priceBelowLowerNow = candle.close < lower; + + if (priceBelowLowerPrev && !priceBelowLowerNow) { + markers.push({ + time: candle.time, + position: 'belowBar', + color: buyColor, + shape: buyShape === 'custom' ? '' : buyShape, + text: buyShape === 'custom' ? buyCustom : '' + }); + } + + if (!priceBelowLowerPrev && priceBelowLowerNow) { + markers.push({ + time: candle.time, + position: 'aboveBar', + color: sellColor, + shape: sellShape === 'custom' ? '' : sellShape, + text: sellShape === 'custom' ? sellCustom : '' + }); + } + } else if (indicatorType === 'hurst') { + const upper = result.upper; + const lower = result.lower; + const prevUpper = prevResult?.upper; + const prevLower = prevResult?.lower; + + if (upper === undefined || lower === undefined || + prevUpper === undefined || prevLower === undefined) continue; + + // BUY: price crosses down below lower band (was above, now below) + if (prevCandle.close > prevLower && candle.close < lower) { + markers.push({ + time: candle.time, + position: 'belowBar', + color: buyColor, + shape: buyShape === 'custom' ? '' : buyShape, + text: buyShape === 'custom' ? buyCustom : '' + }); + } + + // SELL: price crosses down below upper band (was above, now below) + if (prevCandle.close > prevUpper && candle.close < upper) { + markers.push({ + time: candle.time, + position: 'aboveBar', + color: sellColor, + shape: sellShape === 'custom' ? '' : sellShape, + text: sellShape === 'custom' ? sellCustom : '' + }); + } + } else { + const ma = result.ma ?? result; + const prevMa = prevResult.ma ?? prevResult; + + if (ma === undefined || prevMa === undefined) continue; + + const priceAbovePrev = prevCandle.close > prevMa; + const priceAboveNow = candle.close > ma; + + if (priceAbovePrev && !priceAboveNow) { + markers.push({ + time: candle.time, + position: 'aboveBar', + color: sellColor, + shape: sellShape === 'custom' ? '' : sellShape, + text: sellShape === 'custom' ? sellCustom : '' + }); + } + + if (!priceAbovePrev && priceAboveNow) { + markers.push({ + time: candle.time, + position: 'belowBar', + color: buyColor, + shape: buyShape === 'custom' ? '' : buyShape, + text: buyShape === 'custom' ? buyCustom : '' + }); + } + } + } + + return markers; +} diff --git a/js/ui/signals-calculator.js b/js/ui/signals-calculator.js new file mode 100644 index 0000000..b1e8e62 --- /dev/null +++ b/js/ui/signals-calculator.js @@ -0,0 +1,367 @@ +// Signal Calculator - orchestrates signal calculation using indicator-specific functions +// Signal calculation logic is now in each indicator file + +import { IndicatorRegistry, getSignalFunction } from '../indicators/index.js'; + +/** + * Calculate signal for an indicator + * @param {Object} indicator - Indicator configuration + * @param {Array} candles - Candle data array + * @param {Object} indicatorValues - Computed indicator values for last candle + * @param {Object} prevIndicatorValues - Computed indicator values for previous candle + * @returns {Object} Signal object with type, strength, value, reasoning + */ +function calculateIndicatorSignal(indicator, candles, indicatorValues, prevIndicatorValues) { + const signalFunction = getSignalFunction(indicator.type); + + if (!signalFunction) { + console.warn('[Signals] No signal function for indicator type:', indicator.type); + return null; + } + + const lastCandle = candles[candles.length - 1]; + const prevCandle = candles[candles.length - 2]; + + return signalFunction(indicator, lastCandle, prevCandle, indicatorValues, prevIndicatorValues); +} + +/** + * Calculate aggregate summary signal from all indicators + */ +export function calculateSummarySignal(signals) { + console.log('[calculateSummarySignal] Input signals:', signals?.length); + + if (!signals || signals.length === 0) { + return { + signal: 'hold', + strength: 0, + reasoning: 'No active indicators', + buyCount: 0, + sellCount: 0, + holdCount: 0 + }; + } + + const buySignals = signals.filter(s => s.signal === 'buy'); + const sellSignals = signals.filter(s => s.signal === 'sell'); + const holdSignals = signals.filter(s => s.signal === 'hold'); + + const buyCount = buySignals.length; + const sellCount = sellSignals.length; + const holdCount = holdSignals.length; + const total = signals.length; + + console.log('[calculateSummarySignal] BUY:', buyCount, 'SELL:', sellCount, 'HOLD:', holdCount); + + const buyWeight = buySignals.reduce((sum, s) => sum + (s.strength || 0), 0); + const sellWeight = sellSignals.reduce((sum, s) => sum + (s.strength || 0), 0); + + let summarySignal, strength, reasoning; + + if (buyCount > sellCount && buyCount > holdCount) { + summarySignal = 'buy'; + const avgBuyStrength = buyWeight / buyCount; + strength = Math.round(avgBuyStrength * (buyCount / total)); + reasoning = `${buyCount} buy signals, ${sellCount} sell, ${holdCount} hold`; + } else if (sellCount > buyCount && sellCount > holdCount) { + summarySignal = 'sell'; + const avgSellStrength = sellWeight / sellCount; + strength = Math.round(avgSellStrength * (sellCount / total)); + reasoning = `${sellCount} sell signals, ${buyCount} buy, ${holdCount} hold`; + } else { + summarySignal = 'hold'; + strength = 30; + reasoning = `Mixed signals: ${buyCount} buy, ${sellCount} sell, ${holdCount} hold`; + } + + const result = { + signal: summarySignal, + strength: Math.min(Math.max(strength, 0), 100), + reasoning, + buyCount, + sellCount, + holdCount, + color: summarySignal === 'buy' ? '#26a69a' : summarySignal === 'sell' ? '#ef5350' : '#787b86' + }; + + console.log('[calculateSummarySignal] Result:', result); + return result; +} + +/** + * Calculate historical crossovers for all indicators based on full candle history + * Finds the last time each indicator crossed from BUY to SELL or SELL to BUY + */ +function calculateHistoricalCrossovers(activeIndicators, candles) { + activeIndicators.forEach(indicator => { + const indicatorType = indicator.type || indicator.indicatorType; + + // Recalculate indicator values for all candles (use cache if valid) + let results = indicator.cachedResults; + if (!results || !Array.isArray(results) || results.length !== candles.length) { + const IndicatorClass = IndicatorRegistry[indicatorType]; + if (!IndicatorClass) return; + const instance = new IndicatorClass(indicator); + results = instance.calculate(candles); + // Don't save back to cache here, let drawIndicatorsOnChart be the source of truth for cache + } + + if (!results || !Array.isArray(results) || results.length === 0) return; + + // Find the most recent crossover by going backwards from the newest candle + // candles are sorted oldest first, newest last + let lastCrossoverTimestamp = null; + let lastSignalType = null; + + // Get indicator-specific parameters + const overbought = indicator.params?.overbought || 70; + const oversold = indicator.params?.oversold || 30; + + for (let i = candles.length - 1; i > 0; i--) { + const candle = candles[i]; // newer candle + const prevCandle = candles[i-1]; // older candle + + const result = results[i]; + const prevResult = results[i-1]; + + if (!result || !prevResult) continue; + + // Handle different indicator types + if (indicatorType === 'rsi' || indicatorType === 'stoch') { + // RSI/Stochastic: check crossing overbought/oversold levels + const rsi = result.rsi !== undefined ? result.rsi : result; + const prevRsi = prevResult.rsi !== undefined ? prevResult.rsi : prevResult; + + if (rsi === undefined || prevRsi === undefined) continue; + + // SELL: crossed down through overbought (was above, now below) + if (prevRsi > overbought && rsi <= overbought) { + lastCrossoverTimestamp = candle.time; + lastSignalType = 'sell'; + break; + } + // BUY: crossed up through oversold (was below, now above) + if (prevRsi < oversold && rsi >= oversold) { + lastCrossoverTimestamp = candle.time; + lastSignalType = 'buy'; + break; + } + } else if (indicatorType === 'hurst') { + // Hurst Bands: check price crossing bands + const upper = result.upper; + const lower = result.lower; + const prevUpper = prevResult.upper; + const prevLower = prevResult.lower; + + if (upper === undefined || lower === undefined || + prevUpper === undefined || prevLower === undefined) continue; + + // BUY: price crossed down below lower band + if (prevCandle.close > prevLower && candle.close < lower) { + lastCrossoverTimestamp = candle.time; + lastSignalType = 'buy'; + break; + } + // SELL: price crossed down below upper band + if (prevCandle.close > prevUpper && candle.close < upper) { + lastCrossoverTimestamp = candle.time; + lastSignalType = 'sell'; + break; + } + } else { + // MA-style: check price crossing MA + const ma = result.ma !== undefined ? result.ma : result; + const prevMa = prevResult.ma !== undefined ? prevResult.ma : prevResult; + + if (ma === undefined || prevMa === undefined) continue; + + // Check crossover: price was on one side of MA, now on the other side + const priceAbovePrev = prevCandle.close > prevMa; + const priceAboveNow = candle.close > ma; + + // SELL signal: price crossed from above to below MA + if (priceAbovePrev && !priceAboveNow) { + lastCrossoverTimestamp = candle.time; + lastSignalType = 'sell'; + break; + } + // BUY signal: price crossed from below to above MA + if (!priceAbovePrev && priceAboveNow) { + lastCrossoverTimestamp = candle.time; + lastSignalType = 'buy'; + break; + } + } + } + + // Always update the timestamp based on current data + // If crossover found use that time, otherwise use last candle time + if (lastCrossoverTimestamp) { + console.log(`[HistoricalCross] ${indicatorType}: Found ${lastSignalType} crossover at ${new Date(lastCrossoverTimestamp * 1000).toLocaleString()}`); + indicator.lastSignalTimestamp = lastCrossoverTimestamp; + indicator.lastSignalType = lastSignalType; + } else { + // No crossover found - use last candle time + const lastCandleTime = candles[candles.length - 1]?.time; + if (lastCandleTime) { + const lastResult = results[results.length - 1]; + + if (indicatorType === 'rsi' || indicatorType === 'stoch') { + // RSI/Stochastic: use RSI level to determine signal + const rsi = lastResult?.rsi !== undefined ? lastResult.rsi : lastResult; + if (rsi !== undefined) { + indicator.lastSignalType = rsi > overbought ? 'sell' : (rsi < oversold ? 'buy' : null); + indicator.lastSignalTimestamp = lastCandleTime; + } + } else if (indicatorType === 'hurst') { + // Hurst Bands: use price vs bands + const upper = lastResult?.upper; + const lower = lastResult?.lower; + const currentPrice = candles[candles.length - 1]?.close; + if (upper !== undefined && lower !== undefined && currentPrice !== undefined) { + if (currentPrice < lower) { + indicator.lastSignalType = 'buy'; + } else if (currentPrice > upper) { + indicator.lastSignalType = 'sell'; + } else { + indicator.lastSignalType = null; + } + indicator.lastSignalTimestamp = lastCandleTime; + } + } else { + // MA-style: use price vs MA + const ma = lastResult?.ma !== undefined ? lastResult.ma : lastResult; + if (ma !== undefined) { + const isAbove = candles[candles.length - 1].close > ma; + indicator.lastSignalType = isAbove ? 'buy' : 'sell'; + indicator.lastSignalTimestamp = lastCandleTime; + } + } + } + } + }); +} + +/** + * Calculate signals for all active indicators + * @returns {Array} Array of indicator signals + */ +export function calculateAllIndicatorSignals() { + const activeIndicators = window.getActiveIndicators?.() || []; + const candles = window.dashboard?.allData?.get(window.dashboard?.currentInterval); + + //console.log('[Signals] ========== calculateAllIndicatorSignals START =========='); + console.log('[Signals] Active indicators:', activeIndicators.length, 'Candles:', candles?.length || 0); + + if (!candles || candles.length < 2) { + //console.log('[Signals] Insufficient candles available:', candles?.length || 0); + return []; + } + + if (!activeIndicators || activeIndicators.length === 0) { + //console.log('[Signals] No active indicators'); + return []; + } + + const signals = []; + + // Calculate crossovers for all indicators based on historical data + calculateHistoricalCrossovers(activeIndicators, candles); + + for (const indicator of activeIndicators) { + const IndicatorClass = IndicatorRegistry[indicator.type]; + if (!IndicatorClass) { + console.log('[Signals] No class for indicator type:', indicator.type); + continue; + } + + // Use cached results if available, otherwise calculate + let results = indicator.cachedResults; + let meta = indicator.cachedMeta; + + if (!results || !meta || !Array.isArray(results) || results.length !== candles.length) { + const instance = new IndicatorClass(indicator); + meta = instance.getMetadata(); + results = instance.calculate(candles); + indicator.cachedResults = results; + indicator.cachedMeta = meta; + } + + if (!results || !Array.isArray(results) || results.length === 0) { + console.log('[Signals] No valid results for indicator:', indicator.type); + continue; + } + + const lastResult = results[results.length - 1]; + const prevResult = results[results.length - 2]; + if (lastResult === null || lastResult === undefined) { + console.log('[Signals] No valid last result for indicator:', indicator.type); + continue; + } + + let values; + let prevValues; + if (typeof lastResult === 'object' && lastResult !== null && !Array.isArray(lastResult)) { + values = lastResult; + prevValues = prevResult; + } else if (typeof lastResult === 'number') { + values = { ma: lastResult }; + prevValues = prevResult ? { ma: prevResult } : undefined; + } else { + console.log('[Signals] Unexpected result type for', indicator.type, ':', typeof lastResult); + continue; + } + + const signal = calculateIndicatorSignal(indicator, candles, values, prevValues); + + let currentSignal = signal; + let lastSignalDate = indicator.lastSignalTimestamp || null; + let lastSignalType = indicator.lastSignalType || null; + + if (!currentSignal || !currentSignal.type) { + console.log('[Signals] No valid signal for', indicator.type, '- Using last signal if available'); + + if (lastSignalType && lastSignalDate) { + currentSignal = { + type: lastSignalType, + strength: 50, + value: candles[candles.length - 1]?.close, + reasoning: `No crossover (price equals MA)` + }; + } else { + console.log('[Signals] No previous signal available - Skipping'); + continue; + } + } else { + const currentCandleTimestamp = candles[candles.length - 1].time; + + if (currentSignal.type !== lastSignalType || !lastSignalType) { + console.log('[Signals] Signal changed for', indicator.type, ':', lastSignalType, '->', currentSignal.type); + lastSignalDate = indicator.lastSignalTimestamp || currentCandleTimestamp; + lastSignalType = currentSignal.type; + indicator.lastSignalTimestamp = lastSignalDate; + indicator.lastSignalType = lastSignalType; + } + } + + signals.push({ + id: indicator.id, + name: meta?.name || indicator.type, + label: indicator.type?.toUpperCase(), + params: meta?.inputs && meta.inputs.length > 0 + ? indicator.params[meta.inputs[0].name] + : null, + type: indicator.type, + signal: currentSignal.type, + strength: Math.round(currentSignal.strength), + value: currentSignal.value, + reasoning: currentSignal.reasoning, + color: currentSignal.type === 'buy' ? '#26a69a' : currentSignal.type === 'sell' ? '#ef5350' : '#787b86', + lastSignalDate: lastSignalDate + }); + } + + //console.log('[Signals] ========== calculateAllIndicatorSignals END =========='); + console.log('[Signals] Total signals calculated:', signals.length); + return signals; +} \ No newline at end of file diff --git a/js/ui/strategy-panel.js b/js/ui/strategy-panel.js new file mode 100644 index 0000000..d768c23 --- /dev/null +++ b/js/ui/strategy-panel.js @@ -0,0 +1,370 @@ +import { getStrategy, registerStrategy } from '../strategies/index.js'; +import { PingPongStrategy } from '../strategies/ping-pong.js'; + +// Register available strategies +registerStrategy('ping_pong', PingPongStrategy); + +let activeIndicators = []; + +function formatDisplayDate(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${day}/${month}/${year} ${hours}:${minutes}`; +} + +export function initStrategyPanel() { + window.renderStrategyPanel = renderStrategyPanel; + renderStrategyPanel(); + + // Listen for indicator changes to update the signal selection list + const originalAddIndicator = window.addIndicator; + window.addIndicator = function(...args) { + const res = originalAddIndicator.apply(this, args); + setTimeout(renderStrategyPanel, 100); + return res; + }; + + const originalRemoveIndicator = window.removeIndicatorById; + window.removeIndicatorById = function(...args) { + const res = originalRemoveIndicator.apply(this, args); + setTimeout(renderStrategyPanel, 100); + return res; + }; +} + +export function renderStrategyPanel() { + const container = document.getElementById('strategyPanel'); + if (!container) return; + + activeIndicators = window.getActiveIndicators?.() || []; + + // For now, we only have Ping-Pong. Later we can add a strategy selector. + const currentStrategyId = 'ping_pong'; + const strategy = getStrategy(currentStrategyId); + + if (!strategy) { + container.innerHTML = ``; + return; + } + + container.innerHTML = ` + + + + `; + + // Attach strategy specific listeners (like disabling dropdowns when auto-detect is on) + if (strategy.attachListeners) { + strategy.attachListeners(); + } + + document.getElementById('runSimulationBtn').addEventListener('click', () => { + strategy.runSimulation(activeIndicators, displayResults); + }); +} + +// Keep the display logic here so all strategies can use the same rendering for results +let equitySeries = null; +let equityChart = null; +let posSeries = null; +let posSizeChart = null; +let tradeMarkers = []; + +function displayResults(trades, equityData, config, endPrice, avgPriceData, posSizeData) { + const resultsDiv = document.getElementById('simulationResults'); + resultsDiv.style.display = 'block'; + + if (window.dashboard) { + window.dashboard.setAvgPriceData(avgPriceData); + } + + const entryTrades = trades.filter(t => t.recordType === 'entry').length; + const exitTrades = trades.filter(t => t.recordType === 'exit').length; + const profitableTrades = trades.filter(t => t.recordType === 'exit' && t.pnl > 0).length; + const winRate = exitTrades > 0 ? (profitableTrades / exitTrades * 100).toFixed(1) : 0; + + const startPrice = equityData.usd[0].value / equityData.btc[0].value; + const startBtc = config.capital / startPrice; + + const finalUsd = equityData.usd[equityData.usd.length - 1].value; + const finalBtc = finalUsd / endPrice; + + const totalPnlUsd = finalUsd - config.capital; + const roi = (totalPnlUsd / config.capital * 100).toFixed(2); + + const roiBtc = ((finalBtc - startBtc) / startBtc * 100).toFixed(2); + + resultsDiv.innerHTML = ` + + `; + + // Create Charts + const initCharts = () => { + const equityContainer = document.getElementById('equityChart'); + if (equityContainer) { + equityContainer.innerHTML = ''; + equityChart = LightweightCharts.createChart(equityContainer, { + layout: { background: { color: '#131722' }, textColor: '#d1d4dc' }, + grid: { vertLines: { visible: false }, horzLines: { color: '#2a2e39' } }, + rightPriceScale: { borderColor: '#2a2e39', autoScale: true }, + timeScale: { + borderColor: '#2a2e39', + visible: true, + timeVisible: true, + secondsVisible: false, + tickMarkFormatter: (time, tickMarkType, locale) => { + return window.TimezoneConfig ? window.TimezoneConfig.formatTickMark(time) : new Date(time * 1000).toLocaleDateString(); + }, + }, + localization: { + timeFormatter: (timestamp) => { + return window.TimezoneConfig ? window.TimezoneConfig.formatDate(timestamp * 1000) : new Date(timestamp * 1000).toLocaleString(); + }, + }, + handleScroll: true, + handleScale: true + }); + + equitySeries = equityChart.addSeries(LightweightCharts.AreaSeries, { + lineColor: totalPnlUsd >= 0 ? '#26a69a' : '#ef5350', + topColor: totalPnlUsd >= 0 ? 'rgba(38, 166, 154, 0.4)' : 'rgba(239, 83, 80, 0.4)', + bottomColor: 'rgba(0, 0, 0, 0)', + lineWidth: 2, + }); + + equitySeries.setData(equityData['usd']); + equityChart.timeScale().fitContent(); + } + + const posSizeContainer = document.getElementById('posSizeChart'); + if (posSizeContainer) { + posSizeContainer.innerHTML = ''; + posSizeChart = LightweightCharts.createChart(posSizeContainer, { + layout: { background: { color: '#131722' }, textColor: '#d1d4dc' }, + grid: { vertLines: { visible: false }, horzLines: { color: '#2a2e39' } }, + rightPriceScale: { borderColor: '#2a2e39', autoScale: true }, + timeScale: { + borderColor: '#2a2e39', + visible: true, + timeVisible: true, + secondsVisible: false, + tickMarkFormatter: (time, tickMarkType, locale) => { + return window.TimezoneConfig ? window.TimezoneConfig.formatTickMark(time) : new Date(time * 1000).toLocaleDateString(); + }, + }, + localization: { + timeFormatter: (timestamp) => { + return window.TimezoneConfig ? window.TimezoneConfig.formatDate(timestamp * 1000) : new Date(timestamp * 1000).toLocaleString(); + }, + }, + handleScroll: true, + handleScale: true + }); + + posSeries = posSizeChart.addSeries(LightweightCharts.AreaSeries, { + lineColor: '#00bcd4', + topColor: 'rgba(0, 188, 212, 0.4)', + bottomColor: 'rgba(0, 0, 0, 0)', + lineWidth: 2, + }); + + posSeries.setData(posSizeData['usd']); + posSizeChart.timeScale().fitContent(); + + const label = document.getElementById('posSizeLabel'); + if (label) label.textContent = 'Position Size (USD)'; + } + + if (equityChart && posSizeChart) { + let isSyncing = false; + + const syncCharts = (source, target) => { + if (isSyncing) return; + isSyncing = true; + const range = source.timeScale().getVisibleRange(); + target.timeScale().setVisibleRange(range); + isSyncing = false; + }; + + equityChart.timeScale().subscribeVisibleTimeRangeChange(() => syncCharts(equityChart, posSizeChart)); + posSizeChart.timeScale().subscribeVisibleTimeRangeChange(() => syncCharts(posSizeChart, equityChart)); + } + + const syncToMain = (param) => { + if (!param.time || !window.dashboard || !window.dashboard.chart) return; + + const timeScale = window.dashboard.chart.timeScale(); + const currentRange = timeScale.getVisibleRange(); + if (!currentRange) return; + + const width = currentRange.to - currentRange.from; + const halfWidth = width / 2; + + timeScale.setVisibleRange({ + from: param.time - halfWidth, + to: param.time + halfWidth + }); + }; + + if (equityChart) equityChart.subscribeClick(syncToMain); + if (posSizeChart) posSizeChart.subscribeClick(syncToMain); + }; + + setTimeout(initCharts, 100); + + resultsDiv.querySelectorAll('.toggle-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const unit = btn.dataset.unit; + + resultsDiv.querySelectorAll(`.toggle-btn`).forEach(b => { + if (b.dataset.unit === unit) b.classList.add('active'); + else b.classList.remove('active'); + }); + + if (equitySeries) { + equitySeries.setData(equityData[unit]); + equityChart.timeScale().fitContent(); + } + if (posSeries) { + posSeries.setData(posSizeData[unit]); + posSizeChart.timeScale().fitContent(); + + const label = document.getElementById('posSizeLabel'); + if (label) label.textContent = `Position Size (${unit.toUpperCase()})`; + } + }); + }); + + document.getElementById('toggleTradeMarkers').addEventListener('click', () => { + toggleSimulationMarkers(trades); + }); + + document.getElementById('clearSim').addEventListener('click', () => { + resultsDiv.style.display = 'none'; + clearSimulationMarkers(); + if (window.dashboard) { + window.dashboard.clearAvgPriceData(); + } + }); +} + +function toggleSimulationMarkers(trades) { + if (tradeMarkers.length > 0) { + clearSimulationMarkers(); + document.getElementById('toggleTradeMarkers').textContent = 'Show Markers'; + return; + } + + const markers = []; + trades.forEach(t => { + const usdVal = t.currentUsd !== undefined ? `$${t.currentUsd.toFixed(0)}` : '0'; + const qtyVal = t.currentQty !== undefined ? `${t.currentQty.toFixed(4)} BTC` : '0'; + const sizeStr = ` (${usdVal} / ${qtyVal})`; + + if (t.recordType === 'entry') { + markers.push({ + time: t.time, + position: t.type === 'long' ? 'belowBar' : 'aboveBar', + color: t.type === 'long' ? '#2962ff' : '#9c27b0', + shape: t.type === 'long' ? 'arrowUp' : 'arrowDown', + text: `Entry ${t.type.toUpperCase()}${sizeStr}` + }); + } + + if (t.recordType === 'exit') { + markers.push({ + time: t.time, + position: t.type === 'long' ? 'aboveBar' : 'belowBar', + color: t.pnl >= 0 ? '#26a69a' : '#ef5350', + shape: t.type === 'long' ? 'arrowDown' : 'arrowUp', + text: `Exit ${t.reason}${sizeStr}` + }); + } + }); + + markers.sort((a, b) => a.time - b.time); + + if (window.dashboard) { + window.dashboard.setSimulationMarkers(markers); + tradeMarkers = markers; + document.getElementById('toggleTradeMarkers').textContent = 'Hide Markers'; + } +} + +function clearSimulationMarkers() { + if (window.dashboard) { + window.dashboard.clearSimulationMarkers(); + tradeMarkers = []; + } +} + +window.clearSimulationResults = function() { + const resultsDiv = document.getElementById('simulationResults'); + if (resultsDiv) resultsDiv.style.display = 'none'; + clearSimulationMarkers(); +}; \ No newline at end of file diff --git a/js/utils/helpers.js b/js/utils/helpers.js new file mode 100644 index 0000000..03df981 --- /dev/null +++ b/js/utils/helpers.js @@ -0,0 +1,23 @@ +export function downloadFile(content, filename, mimeType) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +export function formatDate(date) { + return new Date(date).toISOString().slice(0, 16); +} + +export function formatPrice(price, decimals = 2) { + return price.toFixed(decimals); +} + +export function formatPercent(value) { + return (value >= 0 ? '+' : '') + value.toFixed(2) + '%'; +} diff --git a/js/utils/index.js b/js/utils/index.js new file mode 100644 index 0000000..ea672c1 --- /dev/null +++ b/js/utils/index.js @@ -0,0 +1 @@ +export { downloadFile, formatDate, formatPrice, formatPercent } from './helpers.js';