chore: add AGENTS.md with build, lint, test commands and style guidelines

This commit is contained in:
DiTus
2026-03-18 21:17:43 +01:00
parent e98c25efc4
commit 509f8033fa
32 changed files with 10087 additions and 133 deletions

251
AGENTS.md
View File

@ -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 -- <testFilePath>`
- 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",
"build": "tsc --project tsconfig.build.json",
"lint": "eslint . --ext .ts,.tsx,.js,.jsx",
"format": "prettier --write .",
"test": "jest",
"coverage": "jest --coverage"
}
"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 <testName>` 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 treeshaking 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 rerun tests on file changes.
---
## 2. Code Style Guidelines
### 2.1 File Organization
- Keep related components, utilities, and hooks in featurespecific directories.
- Use index files (`index.js`) to reexport public APIs from each folder.
### Imports
1. **Core modules** place Node builtin imports last.
2. **Thirdparty 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';
```ts
// Good
import { useEffect } from 'react';
import { formatDate } from '@utils/date';
import { User } from './types';
import { apiClient } from './api/client';
// Bad
import { foo } from './foo';
import React from 'react';
```
- **Default imports** only for modules that expose a default export:
```js
import defaultFn from './default';
```
- **Relative paths** (`./`, `../`) must be used; no rootpath shortcuts.
### 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.
### Formatting
- Use Prettier with the shared `.prettierrc`.
- Enforce 2space indentation.
- Keep line length ≤ 100 characters; wrap when necessary.
- Always include a trailing newline.
### 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`.
### 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.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.
### 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.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`).
### Error Handling
- Throw `AppError` (or a subclass) for domainspecific 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.
### 2.7 Async/Await
### 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 thirdparty 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: `<type>(<scope>): <subject>`
- Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
- Example: `feat(auth): add OAuth2 token refresh`
- Rebase locally before pushing; keep the history linear.
- Never forcepush to `main`; use featurebranch PRs.
### 2.9 Testing Conventions
- **Arrange**: Setup → Exercise → Verify.
- **Describe**: Use `describe` blocks for logical groups.
- **It**: Use `it` with a clear, pasttense description.
- **BeforeEach / AfterEach**: Clean up mocks and state.
### CI/CD
- GitHub Actions run on every PR:
1. Lint
2. Typecheck (`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.
- Endtoend tests live under `e2e/` and use Playwright.
---
## 3. ProjectSpecific 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 multistep work. Only one task may be `in_progress` at a time.
2. **File Access** Read before editing; always preserve indentation and linenumber 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/
```
### 4.2 Reset Lint Cache
```bash
# Clear ESLint cache to force relint
rm -rf .eslintcache
```
### 4.3 Generate Documentation (if used)
```bash
# Assuming JSDoc is configured
jsdoc -c jsdoc.conf.json -r src/ -o docs/
```
| 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 <name>` | 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 |
---
## 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.*
*End of document*

9
config.js Normal file
View File

@ -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'
};

726
css/indicators-new.css Normal file
View File

@ -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);
}

1527
index.html Normal file

File diff suppressed because it is too large Load Diff

82
js/app.js Normal file
View File

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

76
js/config/timezone.js Normal file
View File

@ -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 };

15
js/core/constants.js Normal file
View File

@ -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';

1
js/core/index.js Normal file
View File

@ -0,0 +1 @@
export { INTERVALS, COLORS, API_BASE } from './constants.js';

118
js/indicators/atr.js Normal file
View File

@ -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 };

118
js/indicators/bb.js Normal file
View File

@ -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 };

255
js/indicators/hts.js Normal file
View File

@ -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 };

421
js/indicators/hurst.js Normal file
View File

@ -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 };

69
js/indicators/index.js Normal file
View File

@ -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;
}

153
js/indicators/macd.js Normal file
View File

@ -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 };

View File

@ -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 };

141
js/indicators/rsi.js Normal file
View File

@ -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 };

139
js/indicators/stoch.js Normal file
View File

@ -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 };

9
js/strategies/index.js Normal file
View File

@ -0,0 +1,9 @@
export const StrategyRegistry = {};
export function registerStrategy(name, strategyModule) {
StrategyRegistry[name] = strategyModule;
}
export function getStrategy(name) {
return StrategyRegistry[name];
}

612
js/strategies/ping-pong.js Normal file
View File

@ -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 '<div style="padding: 8px; color: var(--tv-text-secondary); font-size: 11px;">No active indicators on chart</div>';
}
return activeIndicators.map(ind => `
<label class="checklist-item">
<input type="checkbox" data-id="${ind.id}" class="sim-${prefix}-check">
<span>${ind.name}</span>
</label>
`).join('');
};
const autoDirChecked = saved?.autoDirection === true;
const disableManualStr = autoDirChecked ? 'disabled' : '';
return `
<div class="sim-input-group">
<label>Start Date & Time</label>
<input type="text" id="simStartDate" class="sim-input" value="${startDisplay}" placeholder="DD/MM/YYYY HH:MM">
</div>
<div class="sim-input-group">
<label>Stop Date & Time (Optional)</label>
<input type="text" id="simStopDate" class="sim-input" value="${stopDisplay}" placeholder="DD/MM/YYYY HH:MM">
</div>
<div class="sim-input-group" style="background: rgba(38, 166, 154, 0.1); padding: 8px; border-radius: 4px; border: 1px solid rgba(38, 166, 154, 0.2);">
<label class="checklist-item" style="margin-bottom: 0;">
<input type="checkbox" id="simAutoDirection" ${autoDirChecked ? 'checked' : ''}>
<span style="color: #26a69a; font-weight: bold;">Auto-Detect Direction (1D MA44)</span>
</label>
<div style="font-size: 10px; color: var(--tv-text-secondary); margin-left: 24px; margin-top: 4px;">
Price > MA44: LONG (Inverse/BTC Margin)<br>
Price < MA44: SHORT (Linear/USDT Margin)
</div>
</div>
<div class="sim-input-group">
<label>Contract Type (Manual)</label>
<select id="simContractType" class="sim-input" ${disableManualStr}>
<option value="linear" ${saved?.contractType === 'linear' ? 'selected' : ''}>Linear (USDT-Margined)</option>
<option value="inverse" ${saved?.contractType === 'inverse' ? 'selected' : ''}>Inverse (Coin-Margined)</option>
</select>
</div>
<div class="sim-input-group">
<label>Direction (Manual)</label>
<select id="simDirection" class="sim-input" ${disableManualStr}>
<option value="long" ${saved?.direction === 'long' ? 'selected' : ''}>Long</option>
<option value="short" ${saved?.direction === 'short' ? 'selected' : ''}>Short</option>
</select>
</div>
<div class="sim-input-group">
<label>Initial Capital ($)</label>
<input type="number" id="simCapital" class="sim-input" value="${saved?.capital || '10000'}" min="1">
</div>
<div class="sim-input-group">
<label>Exchange Leverage (Ping Size Multiplier)</label>
<input type="number" id="simExchangeLeverage" class="sim-input" value="${saved?.exchangeLeverage || '1'}" min="1" max="100">
</div>
<div class="sim-input-group">
<label>Max Effective Leverage (Total Account Cap)</label>
<input type="number" id="simMaxEffectiveLeverage" class="sim-input" value="${saved?.maxEffectiveLeverage || '5'}" min="1" max="100">
</div>
<div class="sim-input-group">
<label>Position Size ($ Margin per Ping)</label>
<input type="number" id="simPosSize" class="sim-input" value="${saved?.posSize || '10'}" min="1">
</div>
<div class="sim-input-group">
<label>Take Profit (%)</label>
<input type="number" id="simTP" class="sim-input" value="${saved?.tp || '15'}" step="0.1" min="0.1">
</div>
<div class="sim-input-group">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label style="margin-bottom: 0;">Open Signal Indicators</label>
<button class="action-btn-text" id="saveSimSettings" style="font-size: 10px; color: #00bcd4; background: none; border: none; cursor: pointer; padding: 0;">Save Defaults</button>
</div>
<div class="indicator-checklist" id="openSignalsList">
${renderIndicatorChecklist('open')}
</div>
</div>
<div class="sim-input-group">
<label>Close Signal Indicators (Empty = Accumulation)</label>
<div class="indicator-checklist" id="closeSignalsList">
${renderIndicatorChecklist('close')}
</div>
</div>
`;
},
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';
}
}
};

1061
js/ui/chart.js Normal file

File diff suppressed because it is too large Load Diff

243
js/ui/hts-visualizer.js Normal file
View File

@ -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;
}

14
js/ui/index.js Normal file
View File

@ -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';

File diff suppressed because it is too large Load Diff

View File

@ -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 = `
<div class="indicator-panel">
<!-- Search Bar -->
<div class="indicator-search">
<span class="search-icon">🔍</span>
<input
type="text"
id="indicatorSearch"
placeholder="Search indicators..."
value="${searchQuery}"
autocomplete="off"
>
${searchQuery ? `<button class="search-clear">×</button>` : ''}
</div>
<!-- Categories -->
<div class="category-tabs">
${CATEGORIES.map(cat => `
<button class="category-tab ${selectedCategory === cat.id ? 'active' : ''}" data-category="${cat.id}">
${cat.icon} ${cat.name}
</button>
`).join('')}
</div>
<!-- Favorites (if any) -->
${[...favoriteIds].length > 0 ? `
<div class="indicator-section favorites">
<div class="section-title">★ Favorites</div>
${[...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('')}
</div>
` : ''}
<!-- Active Indicators -->
${activeIndicators.length > 0 ? `
<div class="indicator-section active">
<div class="section-title">
${activeIndicators.length} Active
${activeIndicators.length > 0 ? `<button class="clear-all">Clear All</button>` : ''}
</div>
${activeIndicators.slice().reverse().map(ind => renderActiveIndicator(ind)).join('')}
</div>
` : ''}
<!-- Available Indicators -->
${catalog.length > 0 ? `
<div class="indicator-section catalog">
<div class="section-title">Available Indicators</div>
${catalog.map(ind => renderIndicatorItem(ind, false)).join('')}
</div>
` : `
<div class="no-results">
No indicators found
</div>
`}
</div>
`;
// Only setup event listeners once
if (!listenersAttached) {
setupEventListeners();
listenersAttached = true;
}
}
function renderIndicatorItem(indicator, isFavorite) {
const colorDots = '';
return `
<div class="indicator-item ${isFavorite ? 'favorite' : ''}" data-type="${indicator.type}">
<div class="indicator-item-main">
<span class="indicator-name">${indicator.name}</span>
<span class="indicator-desc">${indicator.description || ''}</span>
</div>
<div class="indicator-actions">
<button class="indicator-btn add" data-type="${indicator.type}" title="Add to chart">+</button>
${isFavorite ? '' : `
<button class="indicator-btn favorite" data-type="${indicator.type}" title="Add to favorites">
${userPresets.favorites?.includes(indicator.type) ? '★' : '☆'}
</button>
`}
</div>
</div>
`;
}
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 `<div class="indicator-presets">
<button class="preset-indicator" title="${hasPresets.length} saved presets">💾</button>
</div>`;
}();
return `
<div class="indicator-item active ${isExpanded ? 'expanded' : ''}" data-id="${indicator.id}">
<div class="indicator-item-main" onclick="window.toggleIndicatorExpand && window.toggleIndicatorExpand('${indicator.id}');">
<div class="drag-handle" title="Drag to reorder">⋮⋮</div>
<button class="indicator-btn visible" onclick="event.stopPropagation(); window.toggleIndicatorVisibility && window.toggleIndicatorVisibility('${indicator.id}')" title="${indicator.visible !== false ? 'Hide' : 'Show'}">
${indicator.visible !== false ? '👁' : '👁‍🗨'}
</button>
<span class="indicator-name">${label}</span>
${showPresets}
<button class="indicator-btn favorite" onclick="event.stopPropagation(); window.toggleFavorite && window.toggleFavorite('${indicator.type}')" title="Add to favorites">
${isFavorite ? '★' : '☆'}
</button>
<button class="indicator-btn expand ${isExpanded ? 'rotated' : ''}" title="Show settings">
${isExpanded ? '▼' : '▶'}
</button>
</div>
${isExpanded ? `
<div class="indicator-config">
${typeof renderIndicatorConfig === 'function' ? renderIndicatorConfig(indicator, meta) : ''}
</div>
` : ''}
</div>
`;
}
function renderPresetIndicatorIndicator(meta, indicator) {
const hasPresets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : [];
if (!hasPresets || hasPresets.length === 0) return '';
return `<button class="preset-indicator" title="${hasPresets.length} saved presets" onclick="event.stopPropagation(); window.showPresets && window.showPresets('${meta.name}')">💾</button>`;
}
function renderIndicatorConfig(indicator, meta) {
const plotGroups = groupPlotsByColor(meta?.plots || []);
return `
<div class="config-sections">
<!-- Colors -->
<div class="config-section">
<div class="section-subtitle">Visual Settings</div>
${plotGroups.map(group => {
const firstIdx = group.indices[0];
const color = indicator.params[`_color_${firstIdx}`] || meta.plots[firstIdx]?.color || getDefaultColor(activeIndicators.indexOf(indicator));
return `
<div class="config-row">
<label>${group.name} Color</label>
<div class="color-picker">
<input type="color" id="color_${indicator.id}_${firstIdx}" value="${color}" onchange="window.updateIndicatorColor && window.updateIndicatorColor('${indicator.id}', ${firstIdx}, this.value)">
<span class="color-preview" style="background: ${color};"></span>
</div>
</div>
`.trim() + '';
}).join('')}
${indicator.type !== 'rsi' ? `
<div class="config-row">
<label>Line Type</label>
<select onchange="window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '_lineType', this.value)">
${LINE_TYPES.map(lt => `<option value="${lt}" ${indicator.params._lineType === lt ? 'selected' : ''}>${lt.charAt(0).toUpperCase() + lt.slice(1)}</option>`).join('')}
</select>
</div>
<div class="config-row">
<label>Line Width</label>
<input type="range" min="1" max="5" value="${indicator.params._lineWidth || 2}" onchange="this.nextElementSibling.textContent = this.value; window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '_lineWidth', parseInt(this.value))">
<span class="range-value">${indicator.params._lineWidth || 2}</span>
</div>
` : ''}
</div>
${meta?.inputs && meta.inputs.length > 0 ? `
<div class="config-section">
<div class="section-subtitle">Parameters</div>
${meta.inputs.map(input => `
<div class="config-row">
<label>${input.label}</label>
${input.type === 'select' ?
`<select onchange="window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '${input.name}', this.value)">
${input.options.map(o => `<option value="${o}" ${indicator.params[input.name] === o ? 'selected' : ''}>${o}</option>`).join('')}
</select>` :
`<input
type="number"
value="${indicator.params[input.name]}"
${input.min !== undefined ? `min="${input.min}"` : ''}
${input.max !== undefined ? `max="${input.max}"` : ''}
${input.step !== undefined ? `step="${input.step}"` : ''}
onchange="window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '${input.name}', parseFloat(this.value))"
>`
}
</div>
`).join('')}
</div>
` : ''}
</div>
${meta?.inputs && meta.inputs.length > 0 ? `
<div class="config-section">
<div class="section-subtitle">Parameters</div>
${meta.inputs.map(input => `
${console.log("[DEBUG] Input:", input.name, "value:", indicator.params[input.name])}`
<div class="config-row">
<label>${input.label}</label>
${input.type === 'select' ?
`<select onchange="window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '${input.name}', this.value)">
${input.options.map(o => `<option value="${o}" ${indicator.params[input.name] === o ? 'selected' : ''}>${o}</option>`).join('')}
</select>` :
`<input
type="number"
value="${indicator.params[input.name]}"
${input.min !== undefined ? `min="${input.min}"` : ''}
${input.max !== undefined ? `max="${input.max}"` : ''}
${input.step !== undefined ? `step="${input.step}"` : ''}
onchange="window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '${input.name}', parseFloat(this.value))"
>`
}
</div>
`).join('')}
</div>
` : ''}
<div class="config-section">
<div class="section-subtitle">
Presets
<button class="preset-action-btn" onclick="window.savePreset && window.savePreset('${indicator.id}')">+ Save Preset</button>
</div>
${typeof renderIndicatorPresets === 'function' ? renderIndicatorPresets(indicator, meta) : ''}
</div>
<div class="config-actions">
<button class="btn-secondary" onclick="window.resetIndicator && window.resetIndicator('${indicator.id}')">Reset to Defaults</button>
<button class="btn-danger" onclick="window.removeIndicator && window.removeIndicator('${indicator.id}')">Remove</button>
</div>
</div>
`;
}
function renderIndicatorPresets(indicator, meta) {
const presets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : [];
return presets.length > 0 ? `
<div class="presets-list">
${presets.map(p => {
const isApplied = meta.inputs.every(input =>
(indicator.params[input.name] === (preset.values?.[input.name] ?? input.default))
);
return `
<div class="preset-item ${isApplied ? 'applied' : ''}" data-preset="${preset.id}">
<span class="preset-label" onclick="window.applyPreset && window.applyPreset('${indicator.id}', '${preset.id}')">${preset.name}</span>
<button class="preset-delete" onclick="window.deletePreset && window.deletePreset('${preset.id}')">×</button>
</div>
`;
}).join('')}
</div>
` : '<div class="no-presets">No saved presets</div>';
}
// 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 =
'<html><head><title>Presets - ' + indicatorName + '</title><style>' +
'body { font-family: sans-serif; padding: 20px; background: #1e222d; color: #d1d4dc; }' +
'.preset { padding: 10px; margin: 5px; background: #131722; border-radius: 4px; }' +
'.preset:hover { background: #2a2e39; cursor: pointer; }' +
'</style></head><body>' +
'<h3>' + indicatorName + ' Presets</h3>';
presets.forEach(p => {
htmlContent += '<div class="preset" onclick="opener.applyPresetFromWindow(' + "'" + p.id + "'" + ')">' + p.name + '</div>';
});
htmlContent += '</body></html>';
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
};

703
js/ui/indicators-panel.js Normal file
View File

@ -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 = `
<div class="indicator-catalog">
${available.map(ind => `
<div class="indicator-catalog-item ${previewingType === ind.type ? 'previewing' : ''}"
title="${ind.description || ''}"
data-type="${ind.type}">
<span class="indicator-catalog-name">${ind.name}</span>
<span class="indicator-catalog-add" data-type="${ind.type}">+</span>
</div>
`).join('')}
</div>
${activeIndicators.length > 0 ? `
<div class="indicator-active-divider">Active</div>
<div class="indicator-active-list">
${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 `<span class="indicator-color-dot" style="background: ${color};"></span>`;
}).join('');
const label = getIndicatorLabel(ind);
return `
<div class="indicator-active-item ${isConfiguring ? 'configuring' : ''}"
data-id="${ind.id}">
<span class="indicator-active-eye" data-id="${ind.id}"
title="${ind.visible !== false ? 'Hide' : 'Show'}">
${ind.visible !== false ? '👁' : '👁‍🗨'}
</span>
<span class="indicator-active-name" data-id="${ind.id}">${label}</span>
${colorDots}
<button class="indicator-config-btn ${isConfiguring ? 'active' : ''}"
data-id="${ind.id}" title="Configure">⚙</button>
<button class="indicator-remove-btn"
data-id="${ind.id}" title="Remove">×</button>
</div>
`;
}).join('')}
</div>
` : ''}
`;
// 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 = '<div style="text-align: center; color: var(--tv-text-secondary); padding: 20px; font-size: 12px;">Click an indicator to preview its settings</div>';
}
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 = `
<div style="font-size: 11px; color: var(--tv-blue); margin-bottom: 4px; font-weight: 600;">${meta.name}</div>
<div style="font-size: 11px; color: var(--tv-text-secondary); margin-bottom: 10px;">${meta.description || ''}</div>
${meta.inputs.map(input => `
<div style="margin-bottom: 8px;">
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">${input.label}</label>
${input.type === 'select' ?
`<select class="sim-input" style="font-size: 12px; padding: 6px;" disabled>${input.options.map(o => `<option ${input.default === o ? 'selected' : ''}>${o}</option>`).join('')}</select>` :
`<input type="number" class="sim-input" value="${input.default}" ${input.step !== undefined ? `step="${input.step}"` : ''} style="font-size: 12px; padding: 6px;" disabled>`
}
</div>
`).join('')}
<div style="font-size: 10px; color: var(--tv-text-secondary); margin-top: 8px; text-align: center;">Double-click to add to chart</div>
`;
}
/** 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 = '<div style="color: var(--tv-red);">Error loading indicator</div>';
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 `
<div style="margin-bottom: 8px;">
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">${group.name} Color</label>
<input type="color" id="config__color_${firstIdx}" value="${color}" style="width: 100%; height: 28px; border: 1px solid var(--tv-border); border-radius: 4px; cursor: pointer; background: var(--tv-bg);">
</div>
`;
}).join('');
container.innerHTML = `
<div style="font-size: 11px; color: var(--tv-blue); margin-bottom: 8px; font-weight: 600;">${getIndicatorLabel(indicator)}</div>
${colorInputs}
<div style="margin-bottom: 8px;">
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">Line Type</label>
<select id="config__lineType" class="sim-input" style="font-size: 12px; padding: 6px;">
${LINE_TYPES.map(lt => `<option value="${lt}" ${indicator.params._lineType === lt ? 'selected' : ''}>${lt.charAt(0).toUpperCase() + lt.slice(1)}</option>`).join('')}
</select>
</div>
<div style="margin-bottom: 8px;">
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">Line Width</label>
<input type="number" id="config__lineWidth" class="sim-input" value="${indicator.params._lineWidth || 1}" min="1" max="5" style="font-size: 12px; padding: 6px;">
</div>
${meta.inputs.map(input => `
<div style="margin-bottom: 8px;">
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">${input.label}</label>
${input.type === 'select' ?
`<select id="config_${input.name}" class="sim-input" style="font-size: 12px; padding: 6px;">${input.options.map(o => `<option value="${o}" ${indicator.params[input.name] === o ? 'selected' : ''}>${o}</option>`).join('')}</select>` :
`<input type="number" id="config_${input.name}" class="sim-input" value="${indicator.params[input.name]}" ${input.min !== undefined ? `min="${input.min}"` : ''} ${input.max !== undefined ? `max="${input.max}"` : ''} ${input.step !== undefined ? `step="${input.step}"` : ''} style="font-size: 12px; padding: 6px;">`
}
</div>
`).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 `
<div class="legend-item ${dimmed ? 'legend-dimmed' : ''} ${ind.id === configuringId ? 'legend-selected' : ''}"
data-id="${ind.id}">
<span class="legend-dot" style="background: ${firstColor};"></span>
<span class="legend-label">${label}</span>
<span class="legend-close" data-id="${ind.id}" title="Remove">&times;</span>
</div>
`;
}).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;

117
js/ui/markers-plugin.js Normal file
View File

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

73
js/ui/sidebar.js Normal file
View File

@ -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);
}

231
js/ui/signal-markers.js Normal file
View File

@ -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;
}

367
js/ui/signals-calculator.js Normal file
View File

@ -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;
}

370
js/ui/strategy-panel.js Normal file
View File

@ -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 = `<div class="sidebar-section">Strategy not found.</div>`;
return;
}
container.innerHTML = `
<div class="sidebar-section">
<div class="sidebar-section-header">
<span>⚙️</span> ${strategy.name} Strategy
</div>
<div class="sidebar-section-content">
${strategy.renderUI(activeIndicators, formatDisplayDate)}
<button class="sim-run-btn" id="runSimulationBtn">Run Simulation</button>
</div>
</div>
<div id="simulationResults" class="sim-results" style="display: none;">
<!-- Results will be injected here -->
</div>
`;
// 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 = `
<div class="sidebar-section">
<div class="sidebar-section-header">Results</div>
<div class="sidebar-section-content">
<div class="results-summary">
<div class="result-stat">
<div class="result-stat-value ${totalPnlUsd >= 0 ? 'positive' : 'negative'}">${roi}%</div>
<div class="result-stat-label">ROI (USD)</div>
</div>
<div class="result-stat">
<div class="result-stat-value ${parseFloat(roiBtc) >= 0 ? 'positive' : 'negative'}">${roiBtc}%</div>
<div class="result-stat-label">ROI (BTC)</div>
</div>
</div>
<div class="sim-stat-row">
<span>Starting Balance</span>
<span class="sim-value">$${config.capital.toFixed(0)} / ${startBtc.toFixed(4)} BTC</span>
</div>
<div class="sim-stat-row">
<span>Final Balance</span>
<span class="sim-value">$${finalUsd.toFixed(2)} / ${finalBtc.toFixed(4)} BTC</span>
</div>
<div class="sim-stat-row">
<span>Trades (Entry / Exit)</span>
<span class="sim-value">${entryTrades} / ${exitTrades}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 12px;">
<span style="font-size: 11px; color: var(--tv-text-secondary);">Equity Chart</span>
<div class="chart-toggle-group">
<button class="toggle-btn active" data-unit="usd">USD</button>
<button class="toggle-btn" data-unit="btc">BTC</button>
</div>
</div>
<div class="equity-chart-container" id="equityChart"></div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 12px;">
<span style="font-size: 11px; color: var(--tv-text-secondary);" id="posSizeLabel">Position Size (BTC)</span>
<div class="chart-toggle-group">
<button class="toggle-btn active" data-unit="usd">USD</button>
<button class="toggle-btn" data-unit="btc">BTC</button>
</div>
</div>
<div class="equity-chart-container" id="posSizeChart"></div>
<div class="results-actions">
<button class="action-btn secondary" id="toggleTradeMarkers">Show Markers</button>
<button class="action-btn secondary" id="clearSim">Clear</button>
</div>
</div>
</div>
`;
// 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();
};

23
js/utils/helpers.js Normal file
View File

@ -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) + '%';
}

1
js/utils/index.js Normal file
View File

@ -0,0 +1 @@
export { downloadFile, formatDate, formatPrice, formatPercent } from './helpers.js';