├── test-data └── bot-state.json ├── src ├── app │ ├── favicon.ico │ ├── api │ │ └── spec │ │ │ └── route.ts │ ├── globals.css │ ├── layout.tsx │ ├── page.module.css │ └── page.tsx ├── lib │ ├── indicators │ │ ├── ema.ts │ │ ├── rsi.ts │ │ └── adx.ts │ ├── execution │ │ ├── dryRunExecutor.ts │ │ ├── orderTracker.ts │ │ └── liveExecutor.ts │ ├── virtualBarBuilder.ts │ ├── security │ │ └── keyManager.ts │ ├── spec.ts │ ├── state │ │ ├── statePersistence.ts │ │ └── positionState.ts │ ├── watermellonEngine.ts │ ├── types.ts │ ├── config.ts │ ├── tickStream.ts │ ├── rest │ │ └── restPoller.ts │ ├── peachHybridEngine.ts │ └── bot │ │ └── botRunner.ts └── bot │ └── index.ts ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── next.config.ts ├── data └── bot-state.json ├── .gitignore ├── watermellon-bot.service ├── eslint.config.mjs ├── ecosystem.config.js ├── package.json ├── tsconfig.json ├── env.example ├── Watermellon.pine ├── test-bot.ts ├── test-step-by-step.ts └── README.md /test-data/bot-state.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trum3it/aster-trading-bot/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /src/app/api/spec/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { moonSpec, defaultWatermellonConfig } from "@/lib/spec"; 3 | 4 | export function GET() { 5 | return NextResponse.json({ 6 | ...moonSpec, 7 | defaultConfig: defaultWatermellonConfig, 8 | timestamp: new Date().toISOString(), 9 | }); 10 | } 11 | 12 | -------------------------------------------------------------------------------- /data/bot-state.json: -------------------------------------------------------------------------------- 1 | { 2 | "position": { 3 | "size": 120, 4 | "side": "long", 5 | "avgEntry": 1.22, 6 | "unrealizedPnl": 0, 7 | "lastUpdate": 1763681674643, 8 | "pendingOrder": { 9 | "side": "long", 10 | "size": 120, 11 | "timestamp": 1763681429875 12 | } 13 | }, 14 | "lastBarCloseTime": 1763681675047, 15 | "timestamp": 1763681675109 16 | } -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # next.js 7 | /.next/ 8 | /out/ 9 | 10 | # production 11 | /build 12 | 13 | # debug 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | .pnpm-debug.log* 18 | 19 | # env files 20 | .env* 21 | 22 | 23 | 24 | # typescript 25 | *.tsbuildinfo 26 | next-env.d.ts 27 | -------------------------------------------------------------------------------- /watermellon-bot.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Watermellon Trading Bot for AsterDEX 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=aster 8 | WorkingDirectory=/path/to/aster-bot 9 | Environment="NODE_ENV=production" 10 | ExecStart=/usr/bin/npm run bot 11 | Restart=always 12 | RestartSec=10 13 | StandardOutput=journal 14 | StandardError=journal 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | 19 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from "eslint/config"; 2 | import nextVitals from "eslint-config-next/core-web-vitals"; 3 | import nextTs from "eslint-config-next/typescript"; 4 | 5 | const eslintConfig = defineConfig([ 6 | ...nextVitals, 7 | ...nextTs, 8 | // Override default ignores of eslint-config-next. 9 | globalIgnores([ 10 | // Default ignores of eslint-config-next: 11 | ".next/**", 12 | "out/**", 13 | "build/**", 14 | "next-env.d.ts", 15 | ]), 16 | ]); 17 | 18 | export default eslintConfig; 19 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | // PM2 ecosystem file for long-lived bot process 2 | module.exports = { 3 | apps: [ 4 | { 5 | name: "watermellon-bot", 6 | script: "npm", 7 | args: "run bot", 8 | cwd: __dirname, 9 | instances: 1, 10 | autorestart: true, 11 | watch: false, 12 | max_memory_restart: "500M", 13 | env: { 14 | NODE_ENV: "production", 15 | }, 16 | error_file: "./logs/pm2-error.log", 17 | out_file: "./logs/pm2-out.log", 18 | log_date_format: "YYYY-MM-DD HH:mm:ss Z", 19 | merge_logs: true, 20 | }, 21 | ], 22 | }; 23 | 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aster-bot", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "eslint", 10 | "bot": "tsx src/bot/index.ts" 11 | }, 12 | "dependencies": { 13 | "dotenv": "^16.4.7", 14 | "next": "16.0.3", 15 | "react": "19.2.0", 16 | "react-dom": "19.2.0", 17 | "ws": "^8.18.0", 18 | "zod": "^3.23.8" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^20", 22 | "@types/react": "^19", 23 | "@types/react-dom": "^19", 24 | "@types/ws": "^8.5.12", 25 | "eslint": "^9", 26 | "eslint-config-next": "16.0.3", 27 | "tsx": "^4.19.2", 28 | "typescript": "^5" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: #ffffff; 3 | --foreground: #171717; 4 | } 5 | 6 | @media (prefers-color-scheme: dark) { 7 | :root { 8 | --background: #0a0a0a; 9 | --foreground: #ededed; 10 | } 11 | } 12 | 13 | html, 14 | body { 15 | max-width: 100vw; 16 | overflow-x: hidden; 17 | } 18 | 19 | body { 20 | color: var(--foreground); 21 | background: var(--background); 22 | font-family: Arial, Helvetica, sans-serif; 23 | -webkit-font-smoothing: antialiased; 24 | -moz-osx-font-smoothing: grayscale; 25 | } 26 | 27 | * { 28 | box-sizing: border-box; 29 | padding: 0; 30 | margin: 0; 31 | } 32 | 33 | a { 34 | color: inherit; 35 | text-decoration: none; 36 | } 37 | 38 | @media (prefers-color-scheme: dark) { 39 | html { 40 | color-scheme: dark; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Create Next App", 17 | description: "Generated by create next app", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 28 | {children} 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "react-jsx", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": [ 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | ".next/dev/types/**/*.ts", 31 | "**/*.mts" 32 | ], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/indicators/ema.ts: -------------------------------------------------------------------------------- 1 | export class EMA { 2 | private readonly smoothing: number; 3 | private readonly length: number; 4 | private initialized = false; 5 | private current = 0; 6 | 7 | constructor(length: number) { 8 | if (length <= 0) { 9 | throw new Error("EMA length must be positive"); 10 | } 11 | this.length = length; 12 | this.smoothing = 2 / (length + 1); 13 | } 14 | 15 | update(value: number): number { 16 | if (!this.initialized) { 17 | this.current = value; 18 | this.initialized = true; 19 | return this.current; 20 | } 21 | 22 | this.current = value * this.smoothing + this.current * (1 - this.smoothing); 23 | return this.current; 24 | } 25 | 26 | get value(): number { 27 | if (!this.initialized) { 28 | throw new Error("EMA has not been initialized"); 29 | } 30 | return this.current; 31 | } 32 | 33 | get isReady(): boolean { 34 | return this.initialized; 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/execution/dryRunExecutor.ts: -------------------------------------------------------------------------------- 1 | import type { ExecutionAdapter, TradeInstruction } from "../types"; 2 | 3 | type LogEntry = 4 | | { type: "enter"; side: "long" | "short"; order: TradeInstruction } 5 | | { type: "close"; reason: string; meta?: Record; timestamp: number }; 6 | 7 | export class DryRunExecutor implements ExecutionAdapter { 8 | private readonly history: LogEntry[] = []; 9 | 10 | async enterLong(order: TradeInstruction): Promise { 11 | this.persist({ type: "enter", side: "long", order }); 12 | } 13 | 14 | async enterShort(order: TradeInstruction): Promise { 15 | this.persist({ type: "enter", side: "short", order }); 16 | } 17 | 18 | async closePosition(reason: string, meta?: Record): Promise { 19 | this.persist({ type: "close", reason, meta, timestamp: Date.now() }); 20 | } 21 | 22 | get logs(): LogEntry[] { 23 | return this.history; 24 | } 25 | 26 | private persist(entry: LogEntry) { 27 | this.history.unshift(entry); 28 | const label = entry.type === "enter" ? `ENTER ${entry.side.toUpperCase()}` : "CLOSE"; 29 | const payload = entry.type === "enter" ? entry.order : entry; 30 | console.log(`[DryRun] ${label}`, payload); 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | ASTER_RPC_URL=https://fapi.asterdex.com 2 | ASTER_WS_URL=wss://fstream.asterdex.com/ws 3 | ASTER_API_KEY= 4 | ASTER_API_SECRET= 5 | ASTER_PRIVATE_KEY= 6 | PAIR_SYMBOL=ASTERUSDT-PERP 7 | 8 | # Strategy Type: "watermellon" or "peach-hybrid" 9 | STRATEGY_TYPE=peach-hybrid 10 | 11 | # Virtual Timeframe (milliseconds) 12 | VIRTUAL_TIMEFRAME_MS=30000 13 | 14 | # Risk Management 15 | MAX_POSITION_USDT=10000 16 | MAX_LEVERAGE=5 (10x, 20x, 50x are available for normal version) #premium support 1000x 17 | MAX_FLIPS_PER_HOUR=12 18 | STOP_LOSS_PCT=0 19 | TAKE_PROFIT_PCT=0 20 | USE_STOP_LOSS=false 21 | EMERGENCY_STOP_LOSS_PCT=2.0 22 | MAX_POSITIONS=1 23 | 24 | # Watermellon Strategy Parameters (if STRATEGY_TYPE=watermellon) 25 | EMA_FAST= 26 | EMA_MID= 27 | EMA_SLOW= 28 | RSI_LENGTH= 29 | RSI_MIN_LONG= 30 | RSI_MAX_SHORT= 31 | 32 | # Peach Hybrid V1 System (Trend/Bias) 33 | PEACH_V1_EMA_FAST= 34 | PEACH_V1_EMA_MID= 35 | PEACH_V1_EMA_SLOW= 36 | PEACH_V1_EMA_MICRO_FAST= 37 | PEACH_V1_EMA_MICRO_SLOW= 38 | PEACH_V1_RSI_LENGTH= 39 | PEACH_V1_RSI_MIN_LONG= 40 | PEACH_V1_RSI_MAX_SHORT= 41 | PEACH_V1_MIN_BARS_BETWEEN= 42 | PEACH_V1_MIN_MOVE_PCT= 43 | 44 | # Peach Hybrid V2 System (Momentum Surge) 45 | PEACH_V2_EMA_FAST= 46 | PEACH_V2_EMA_MID= 47 | PEACH_V2_EMA_SLOW= 48 | PEACH_V2_RSI_MOMENTUM_THRESHOLD= 49 | PEACH_V2_VOLUME_LOOKBACK= 50 | PEACH_V2_VOLUME_MULTIPLIER= 51 | PEACH_V2_EXIT_VOLUME_MULTIPLIER= 52 | 53 | MODE=dry-run 54 | 55 | -------------------------------------------------------------------------------- /src/lib/virtualBarBuilder.ts: -------------------------------------------------------------------------------- 1 | import type { SyntheticBar, Tick } from "./types"; 2 | 3 | export class VirtualBarBuilder { 4 | private bar: SyntheticBar | null = null; 5 | private readonly timeframeMs: number; 6 | 7 | constructor(timeframeMs: number) { 8 | if (timeframeMs <= 0) { 9 | throw new Error("Timeframe must be positive"); 10 | } 11 | this.timeframeMs = timeframeMs; 12 | } 13 | 14 | pushTick(tick: Tick): { closedBar: SyntheticBar | null; currentBar: SyntheticBar } { 15 | if (!this.bar) { 16 | this.bar = this.createBar(tick); 17 | return { closedBar: null, currentBar: this.bar }; 18 | } 19 | 20 | const elapsed = tick.timestamp - this.bar.startTime; 21 | if (elapsed >= this.timeframeMs) { 22 | const closedBar = this.bar; 23 | this.bar = this.createBar(tick); 24 | return { closedBar, currentBar: this.bar }; 25 | } 26 | 27 | this.bar.high = Math.max(this.bar.high, tick.price); 28 | this.bar.low = Math.min(this.bar.low, tick.price); 29 | this.bar.close = tick.price; 30 | if (tick.size) { 31 | this.bar.volume += tick.size; 32 | } 33 | this.bar.endTime = tick.timestamp; 34 | 35 | return { closedBar: null, currentBar: this.bar }; 36 | } 37 | 38 | private createBar(tick: Tick): SyntheticBar { 39 | return { 40 | startTime: tick.timestamp, 41 | endTime: tick.timestamp, 42 | open: tick.price, 43 | high: tick.price, 44 | low: tick.price, 45 | close: tick.price, 46 | volume: tick.size ?? 0, 47 | }; 48 | } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /src/lib/security/keyManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Key Manager - Ensures API keys and private keys are never logged 3 | * All key-related operations should go through this manager 4 | */ 5 | 6 | export class KeyManager { 7 | private static readonly REDACTED = "***REDACTED***"; 8 | 9 | static redactKey(key: string): string { 10 | if (!key || key.length < 8) { 11 | return this.REDACTED; 12 | } 13 | return `${key.substring(0, 4)}${this.REDACTED}${key.substring(key.length - 4)}`; 14 | } 15 | 16 | static redactCredentials(credentials: { 17 | apiKey?: string; 18 | apiSecret?: string; 19 | privateKey?: string; 20 | [key: string]: unknown; 21 | }): Record { 22 | const redacted = { ...credentials }; 23 | if (redacted.apiKey) { 24 | redacted.apiKey = this.redactKey(redacted.apiKey as string); 25 | } 26 | if (redacted.apiSecret) { 27 | redacted.apiSecret = this.redactKey(redacted.apiSecret as string); 28 | } 29 | if (redacted.privateKey) { 30 | redacted.privateKey = this.REDACTED; 31 | } 32 | return redacted; 33 | } 34 | 35 | static safeLog(message: string, data?: Record): void { 36 | if (data) { 37 | const safeData = { ...data }; 38 | // Redact any key-like fields 39 | Object.keys(safeData).forEach((key) => { 40 | const value = safeData[key]; 41 | if (typeof value === "string" && (key.toLowerCase().includes("key") || key.toLowerCase().includes("secret"))) { 42 | safeData[key] = this.redactKey(value); 43 | } 44 | }); 45 | console.log(message, safeData); 46 | } else { 47 | console.log(message); 48 | } 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /Watermellon.pine: -------------------------------------------------------------------------------- 1 | //@version=6 2 | indicator("Watermellon", overlay=true) 3 | 4 | //========== INPUTS ========== 5 | emaFastLen = input.int(8, "Fast EMA", minval=1) 6 | emaMidLen = input.int(21, "Mid EMA", minval=1) 7 | emaSlowLen = input.int(48, "Slow EMA", minval=1) 8 | rsiLen = input.int(14, "RSI Length", minval=2) 9 | rsiMinLong = input.float(42.0, "Min RSI for long look", step=0.1) 10 | rsiMaxShort = input.float(58.0, "Max RSI for short look", step=0.1) 11 | 12 | //========== CORE SERIES ========== 13 | fastEma = ta.ema(close, emaFastLen) 14 | midEma = ta.ema(close, emaMidLen) 15 | slowEma = ta.ema(close, emaSlowLen) 16 | rsiVal = ta.rsi(close, rsiLen) 17 | 18 | //========== PLOTS (price stuff only) ========== 19 | plot(fastEma, title="EMA Fast", color=color.new(color.orange, 0)) 20 | plot(midEma, title="EMA Mid", color=color.new(color.teal, 0)) 21 | plot(slowEma, title="EMA Slow", color=color.new(color.purple, 0)) 22 | 23 | //========== TREND STATES ========== 24 | bullStack = fastEma > midEma and midEma > slowEma 25 | bearStack = fastEma < midEma and midEma < slowEma 26 | 27 | //========== SIGNALS (LOOSE) ========== 28 | longLook = bullStack and rsiVal > rsiMinLong 29 | shortLook = bearStack and rsiVal < rsiMaxShort 30 | 31 | // trigger once 32 | longTrig = longLook and not longLook[1] 33 | shortTrig = shortLook and not shortLook[1] 34 | 35 | //========== MARKERS ========== 36 | plotshape(longTrig, title="BUY", style=shape.labelup, text="BUY", color=color.new(color.green, 0), location=location.belowbar, size=size.tiny) 37 | plotshape(shortTrig, title="SELL", style=shape.labeldown, text="SELL", color=color.new(color.red, 0), location=location.abovebar, size=size.tiny) 38 | 39 | //========== ALERTS ========== 40 | alertcondition(longTrig, title="BUY signal", message="BUY signal on {{ticker}}") 41 | alertcondition(shortTrig, title="SELL signal", message="SELL signal on {{ticker}}") 42 | 43 | -------------------------------------------------------------------------------- /src/lib/spec.ts: -------------------------------------------------------------------------------- 1 | import type { WatermellonConfig } from "./types"; 2 | 3 | export const defaultWatermellonConfig: WatermellonConfig = { 4 | timeframeMs: 30_000, 5 | emaFastLen: 8, 6 | emaMidLen: 21, 7 | emaSlowLen: 48, 8 | rsiLength: 14, 9 | rsiMinLong: 42, 10 | rsiMaxShort: 58, 11 | }; 12 | 13 | export const moonSpec = { 14 | project: "Watermellon Scalper Bot for AsterDEX", 15 | instrument: "ASTERUSDT perp", 16 | summary: 17 | "Recreate the TradingView Watermellon strategy (3 EMA stack + RSI filters) using synthetic 30-second bars built from raw AsterDEX ticks.", 18 | dataFeed: { 19 | source: "AsterDEX WebSocket ticks", 20 | fields: ["timestamp", "last_price", "size"], 21 | timeframe: "synthetic 30-second OHLCV", 22 | }, 23 | indicators: { 24 | type: "EMA/RSI", 25 | parameters: defaultWatermellonConfig, 26 | notes: [ 27 | "EMA stack acts as trend gate (fast > mid > slow for longs, inverse for shorts).", 28 | "RSI(14) thresholds: >42 for long look, <58 for short look.", 29 | "Signals trigger only on rising edges to avoid duplicate entries.", 30 | ], 31 | }, 32 | tradingRules: [ 33 | "Flat → open long on longTrig, open short on shortTrig.", 34 | "If long and shortTrig fires → close (optional flip).", 35 | "If short and longTrig fires → close (optional flip).", 36 | ], 37 | risk: [ 38 | "Configurable max position size and leverage ceiling.", 39 | "Optional stop-loss / take-profit percentages from entry.", 40 | "Flip budget (per hour/session) to avoid churn.", 41 | "Dedicated wallet for the bot; no withdrawal permissions.", 42 | ], 43 | runtime: { 44 | modes: ["dry-run", "live"], 45 | logging: ["structured JSON logs", "dashboard streaming"], 46 | requirements: [ 47 | "README with setup instructions.", 48 | ".env template covering RPC URLs, keys, and risk params.", 49 | "Single command to start the bot.", 50 | ], 51 | }, 52 | }; 53 | 54 | -------------------------------------------------------------------------------- /src/lib/indicators/rsi.ts: -------------------------------------------------------------------------------- 1 | export class RSI { 2 | private avgGain = 0; 3 | private avgLoss = 0; 4 | private prevValue: number | null = null; 5 | private readonly length: number; 6 | private readonly alpha: number; 7 | private ready = false; 8 | private rsiValue: number | null = null; 9 | private updateCount = 0; 10 | 11 | constructor(length: number) { 12 | if (length < 2) { 13 | throw new Error("RSI length must be at least 2"); 14 | } 15 | this.length = length; 16 | this.alpha = 1 / length; 17 | } 18 | 19 | update(value: number): number { 20 | if (this.prevValue === null) { 21 | this.prevValue = value; 22 | this.rsiValue = 50; 23 | this.updateCount = 1; 24 | return 50; 25 | } 26 | 27 | const delta = value - this.prevValue; 28 | const gain = Math.max(delta, 0); 29 | const loss = Math.max(-delta, 0); 30 | 31 | this.updateCount++; 32 | 33 | // For initial period, use simple average 34 | if (this.updateCount <= this.length) { 35 | this.avgGain = (this.avgGain * (this.updateCount - 1) + gain) / this.updateCount; 36 | this.avgLoss = (this.avgLoss * (this.updateCount - 1) + loss) / this.updateCount; 37 | } else { 38 | // After initial period, use exponential moving average 39 | this.avgGain = this.avgGain * (1 - this.alpha) + gain * this.alpha; 40 | this.avgLoss = this.avgLoss * (1 - this.alpha) + loss * this.alpha; 41 | } 42 | 43 | this.prevValue = value; 44 | 45 | if (!this.ready && this.updateCount >= this.length) { 46 | this.ready = true; 47 | } 48 | 49 | // Handle edge cases 50 | if (this.avgLoss === 0) { 51 | this.rsiValue = this.avgGain > 0 ? 100 : 50; // If no losses, RSI = 100; if no gains/losses, RSI = 50 52 | return this.rsiValue; 53 | } 54 | 55 | if (this.avgGain === 0) { 56 | this.rsiValue = 0; // If no gains, RSI = 0 57 | return this.rsiValue; 58 | } 59 | 60 | const rs = this.avgGain / this.avgLoss; 61 | this.rsiValue = 100 - 100 / (1 + rs); 62 | return this.rsiValue; 63 | } 64 | 65 | get isReady(): boolean { 66 | return this.ready; 67 | } 68 | 69 | get value(): number | null { 70 | return this.rsiValue; 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /src/lib/execution/orderTracker.ts: -------------------------------------------------------------------------------- 1 | import type { TradeInstruction } from "../types"; 2 | 3 | type OrderConfirmation = { 4 | orderId: string; 5 | side: "long" | "short"; 6 | size: number; 7 | price: number; 8 | timestamp: number; 9 | confirmed: boolean; 10 | confirmedAt?: number; 11 | }; 12 | 13 | export class OrderTracker { 14 | private pendingOrders = new Map(); 15 | private readonly confirmationTimeoutMs = 30_000; // 30 seconds 16 | 17 | trackOrder(order: TradeInstruction, orderId: string): void { 18 | this.pendingOrders.set(orderId, { 19 | orderId, 20 | side: order.side, 21 | size: order.size, 22 | price: order.price, 23 | timestamp: order.timestamp, 24 | confirmed: false, 25 | }); 26 | 27 | // Auto-expire unconfirmed orders 28 | setTimeout(() => { 29 | const pending = this.pendingOrders.get(orderId); 30 | if (pending && !pending.confirmed) { 31 | console.warn(`[OrderTracker] Order ${orderId} not confirmed within timeout`); 32 | this.pendingOrders.delete(orderId); 33 | } 34 | }, this.confirmationTimeoutMs); 35 | } 36 | 37 | confirmOrder(orderId: string): boolean { 38 | const order = this.pendingOrders.get(orderId); 39 | if (order) { 40 | order.confirmed = true; 41 | order.confirmedAt = Date.now(); 42 | console.log(`[OrderTracker] Order ${orderId} confirmed`); 43 | return true; 44 | } 45 | return false; 46 | } 47 | 48 | confirmByPositionChange(side: "long" | "short", size: number): void { 49 | // Find matching pending order 50 | for (const [orderId, order] of this.pendingOrders.entries()) { 51 | if (!order.confirmed && order.side === side && Math.abs(order.size - size) < 0.0001) { 52 | this.confirmOrder(orderId); 53 | break; 54 | } 55 | } 56 | } 57 | 58 | hasPendingOrders(): boolean { 59 | return this.pendingOrders.size > 0; 60 | } 61 | 62 | getPendingOrders(): OrderConfirmation[] { 63 | return Array.from(this.pendingOrders.values()); 64 | } 65 | 66 | clearOrder(orderId: string): void { 67 | this.pendingOrders.delete(orderId); 68 | } 69 | 70 | clearAll(): void { 71 | this.pendingOrders.clear(); 72 | } 73 | } 74 | 75 | -------------------------------------------------------------------------------- /src/lib/state/statePersistence.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; 2 | import { join, dirname } from "path"; 3 | import type { LocalPositionState } from "./positionState"; 4 | 5 | type PersistedState = { 6 | position: LocalPositionState; 7 | lastBarCloseTime: number; 8 | timestamp: number; 9 | }; 10 | 11 | export class StatePersistence { 12 | private readonly stateFile: string; 13 | 14 | constructor(dataDir: string = "./data") { 15 | this.stateFile = join(dataDir, "bot-state.json"); 16 | } 17 | 18 | save(state: { position: LocalPositionState; lastBarCloseTime: number }): void { 19 | try { 20 | // Ensure directory exists 21 | const dir = dirname(this.stateFile); 22 | if (!existsSync(dir)) { 23 | mkdirSync(dir, { recursive: true }); 24 | } 25 | const persisted: PersistedState = { 26 | ...state, 27 | timestamp: Date.now(), 28 | }; 29 | writeFileSync(this.stateFile, JSON.stringify(persisted, null, 2), "utf-8"); 30 | } catch (error) { 31 | console.error("[StatePersistence] Failed to save state", error); 32 | } 33 | } 34 | 35 | load(): { position: LocalPositionState; lastBarCloseTime: number } | null { 36 | try { 37 | if (!existsSync(this.stateFile)) { 38 | return null; 39 | } 40 | const content = readFileSync(this.stateFile, "utf-8"); 41 | const persisted: PersistedState = JSON.parse(content); 42 | // Only load if state is less than 1 hour old 43 | const age = Date.now() - persisted.timestamp; 44 | if (age > 60 * 60 * 1000) { 45 | console.log("[StatePersistence] State too old, ignoring"); 46 | return null; 47 | } 48 | return { 49 | position: persisted.position, 50 | lastBarCloseTime: persisted.lastBarCloseTime, 51 | }; 52 | } catch (error) { 53 | console.error("[StatePersistence] Failed to load state", error); 54 | return null; 55 | } 56 | } 57 | 58 | clear(): void { 59 | try { 60 | if (existsSync(this.stateFile)) { 61 | writeFileSync(this.stateFile, "{}", "utf-8"); 62 | } 63 | } catch (error) { 64 | console.error("[StatePersistence] Failed to clear state", error); 65 | } 66 | } 67 | } 68 | 69 | -------------------------------------------------------------------------------- /src/bot/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { resolve } from "path"; 3 | 4 | // Load .env.local first, fallback to .env 5 | dotenv.config({ path: resolve(process.cwd(), ".env.local") }); 6 | dotenv.config({ path: resolve(process.cwd(), ".env") }); 7 | import { BotRunner } from "@/lib/bot/botRunner"; 8 | import { loadConfig } from "@/lib/config"; 9 | import { DryRunExecutor } from "@/lib/execution/dryRunExecutor"; 10 | import { LiveExecutor } from "@/lib/execution/liveExecutor"; 11 | import { AsterTickStream } from "@/lib/tickStream"; 12 | 13 | async function main() { 14 | const config = loadConfig(); 15 | 16 | // Safety warning for live mode 17 | if (config.mode === "live") { 18 | console.warn("=".repeat(80)); 19 | console.warn("⚠️ WARNING: BOT IS RUNNING IN LIVE MODE ⚠️"); 20 | console.warn("=".repeat(80)); 21 | console.warn("This bot will execute REAL trades on AsterDEX with REAL money!"); 22 | console.warn(`Trading pair: ${config.credentials.pairSymbol}`); 23 | console.warn(`Max position size: ${config.risk.maxPositionSize} USDT`); 24 | console.warn(`Max leverage: ${config.risk.maxLeverage}x`); 25 | console.warn("=".repeat(80)); 26 | console.warn("Press Ctrl+C within 5 seconds to cancel..."); 27 | console.warn("=".repeat(80)); 28 | 29 | // Give user 5 seconds to cancel 30 | await new Promise((resolve) => setTimeout(resolve, 5000)); 31 | console.log("Starting bot in LIVE mode...\n"); 32 | } 33 | 34 | const tickStream = new AsterTickStream(config.credentials.wsUrl, config.credentials.pairSymbol); 35 | const executor = 36 | config.mode === "live" ? new LiveExecutor(config.credentials) : new DryRunExecutor(); 37 | 38 | // Confirm which executor is being used 39 | if (config.mode === "live") { 40 | console.log("✅ Using LiveExecutor - REAL trades will be executed on AsterDEX"); 41 | console.log(`✅ API Endpoint: ${config.credentials.rpcUrl}`); 42 | } else { 43 | console.log("ℹ️ Using DryRunExecutor - No real trades will be executed"); 44 | } 45 | 46 | const bot = new BotRunner(config, tickStream, executor); 47 | 48 | bot.on("log", (message, payload) => { 49 | if (payload) { 50 | console.log(`[LOG] ${message}`, payload); 51 | } else { 52 | console.log(`[LOG] ${message}`); 53 | } 54 | }); 55 | 56 | bot.on("position", (position) => { 57 | console.log("[POSITION]", position); 58 | }); 59 | 60 | await bot.start(); 61 | 62 | const shutdown = async () => { 63 | console.log("Received shutdown signal, closing bot..."); 64 | await bot.stop(); 65 | process.exit(0); 66 | }; 67 | 68 | process.on("SIGINT", shutdown); 69 | process.on("SIGTERM", shutdown); 70 | } 71 | 72 | main().catch((error) => { 73 | console.error("Bot failed to start", error); 74 | process.exit(1); 75 | }); 76 | 77 | -------------------------------------------------------------------------------- /src/app/page.module.css: -------------------------------------------------------------------------------- 1 | .page { 2 | --bg: #030712; 3 | --surface: #0f172a; 4 | --border: rgba(255, 255, 255, 0.08); 5 | --text-primary: #f8fafc; 6 | --text-secondary: rgba(248, 250, 252, 0.72); 7 | --accent: #38bdf8; 8 | --accent-soft: rgba(56, 189, 248, 0.1); 9 | 10 | min-height: 100vh; 11 | background: radial-gradient(circle at top, rgba(56, 189, 248, 0.12), transparent), 12 | var(--bg); 13 | color: var(--text-primary); 14 | font-family: var(--font-geist-sans); 15 | padding: 64px 24px 96px; 16 | } 17 | 18 | .main { 19 | max-width: 960px; 20 | margin: 0 auto; 21 | display: flex; 22 | flex-direction: column; 23 | gap: 32px; 24 | } 25 | 26 | .hero { 27 | display: flex; 28 | flex-direction: column; 29 | gap: 16px; 30 | } 31 | 32 | .eyebrow { 33 | font-size: 13px; 34 | letter-spacing: 0.16em; 35 | text-transform: uppercase; 36 | color: var(--accent); 37 | } 38 | 39 | .hero h1 { 40 | font-size: clamp(2.5rem, 4vw, 3.25rem); 41 | margin: 0; 42 | } 43 | 44 | .hero p { 45 | font-size: 1.1rem; 46 | color: var(--text-secondary); 47 | margin: 0; 48 | max-width: 720px; 49 | } 50 | 51 | .grid { 52 | display: grid; 53 | grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); 54 | gap: 20px; 55 | } 56 | 57 | .card { 58 | background: rgba(15, 23, 42, 0.7); 59 | border: 1px solid var(--border); 60 | border-radius: 20px; 61 | padding: 24px; 62 | display: flex; 63 | flex-direction: column; 64 | gap: 12px; 65 | backdrop-filter: blur(12px); 66 | } 67 | 68 | .card h2 { 69 | font-size: 1.1rem; 70 | margin: 0; 71 | } 72 | 73 | .card p, 74 | .card li { 75 | color: var(--text-secondary); 76 | font-size: 0.95rem; 77 | line-height: 1.5; 78 | } 79 | 80 | .card ul { 81 | padding-left: 18px; 82 | margin: 0; 83 | display: flex; 84 | flex-direction: column; 85 | gap: 6px; 86 | } 87 | 88 | .badge { 89 | display: inline-flex; 90 | align-items: center; 91 | gap: 8px; 92 | padding: 6px 12px; 93 | border-radius: 999px; 94 | border: 1px solid var(--border); 95 | font-size: 0.85rem; 96 | color: var(--text-secondary); 97 | } 98 | 99 | .badge strong { 100 | color: var(--text-primary); 101 | font-weight: 600; 102 | } 103 | 104 | .specList { 105 | display: flex; 106 | flex-direction: column; 107 | gap: 24px; 108 | } 109 | 110 | .sectionTitle { 111 | margin-bottom: 8px; 112 | font-size: 0.95rem; 113 | letter-spacing: 0.08em; 114 | text-transform: uppercase; 115 | color: var(--accent); 116 | } 117 | 118 | .sectionBody { 119 | margin: 0; 120 | color: var(--text-secondary); 121 | line-height: 1.6; 122 | } 123 | 124 | .sectionBody strong { 125 | color: #e2e8f0; 126 | } 127 | 128 | @media (max-width: 720px) { 129 | .page { 130 | padding: 48px 16px 64px; 131 | } 132 | 133 | .hero h1 { 134 | font-size: 2.25rem; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/lib/watermellonEngine.ts: -------------------------------------------------------------------------------- 1 | import { EMA } from "./indicators/ema"; 2 | import { RSI } from "./indicators/rsi"; 3 | import type { 4 | IndicatorSnapshot, 5 | StrategySignal, 6 | TrendSnapshot, 7 | WatermellonConfig, 8 | } from "./types"; 9 | 10 | const DEFAULT_CONFIG: WatermellonConfig = { 11 | timeframeMs: 30_000, 12 | emaFastLen: 8, 13 | emaMidLen: 21, 14 | emaSlowLen: 48, 15 | rsiLength: 14, 16 | rsiMinLong: 42, 17 | rsiMaxShort: 58, 18 | }; 19 | 20 | export class WatermellonEngine { 21 | private readonly config: WatermellonConfig; 22 | private readonly emaFast: EMA; 23 | private readonly emaMid: EMA; 24 | private readonly emaSlow: EMA; 25 | private readonly rsi: RSI; 26 | private lastLongLook = false; 27 | private lastShortLook = false; 28 | 29 | constructor(config?: Partial) { 30 | this.config = { ...DEFAULT_CONFIG, ...config }; 31 | this.emaFast = new EMA(this.config.emaFastLen); 32 | this.emaMid = new EMA(this.config.emaMidLen); 33 | this.emaSlow = new EMA(this.config.emaSlowLen); 34 | this.rsi = new RSI(this.config.rsiLength); 35 | } 36 | 37 | update(closePrice: number): StrategySignal { 38 | const emaFastValue = this.emaFast.update(closePrice); 39 | const emaMidValue = this.emaMid.update(closePrice); 40 | const emaSlowValue = this.emaSlow.update(closePrice); 41 | const rsiValue = this.rsi.update(closePrice); 42 | 43 | const indicators: IndicatorSnapshot = { 44 | emaFast: emaFastValue, 45 | emaMid: emaMidValue, 46 | emaSlow: emaSlowValue, 47 | rsi: rsiValue, 48 | }; 49 | 50 | const bullStack = emaFastValue > emaMidValue && emaMidValue > emaSlowValue; 51 | const bearStack = emaFastValue < emaMidValue && emaMidValue < emaSlowValue; 52 | 53 | const longLook = bullStack && rsiValue > this.config.rsiMinLong; 54 | const shortLook = bearStack && rsiValue < this.config.rsiMaxShort; 55 | 56 | const longTrig = longLook && !this.lastLongLook; 57 | const shortTrig = shortLook && !this.lastShortLook; 58 | 59 | this.lastLongLook = longLook; 60 | this.lastShortLook = shortLook; 61 | 62 | const trend: TrendSnapshot = { 63 | bullStack, 64 | bearStack, 65 | longLook, 66 | shortLook, 67 | longTrig, 68 | shortTrig, 69 | }; 70 | 71 | if (longTrig) { 72 | return { type: "long", reason: "long-trigger", indicators, trend }; 73 | } 74 | 75 | if (shortTrig) { 76 | return { type: "short", reason: "short-trigger", indicators, trend }; 77 | } 78 | 79 | return null; 80 | } 81 | 82 | get settings(): WatermellonConfig { 83 | return this.config; 84 | } 85 | 86 | getIndicatorValues(): { 87 | emaFast: number | null; 88 | emaMid: number | null; 89 | emaSlow: number | null; 90 | rsi: number | null; 91 | } { 92 | return { 93 | emaFast: this.emaFast.value, 94 | emaMid: this.emaMid.value, 95 | emaSlow: this.emaSlow.value, 96 | rsi: this.rsi.value, 97 | }; 98 | } 99 | } 100 | 101 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { moonSpec, defaultWatermellonConfig } from "@/lib/spec"; 2 | import styles from "./page.module.css"; 3 | 4 | const configEntries = [ 5 | { label: "EMA Fast", value: defaultWatermellonConfig.emaFastLen }, 6 | { label: "EMA Mid", value: defaultWatermellonConfig.emaMidLen }, 7 | { label: "EMA Slow", value: defaultWatermellonConfig.emaSlowLen }, 8 | { label: "RSI Length", value: defaultWatermellonConfig.rsiLength }, 9 | { label: "RSI > Long", value: defaultWatermellonConfig.rsiMinLong }, 10 | { label: "RSI < Short", value: defaultWatermellonConfig.rsiMaxShort }, 11 | { label: "Virtual TF", value: `${defaultWatermellonConfig.timeframeMs / 1000}s` }, 12 | ]; 13 | 14 | export default function Home() { 15 | return ( 16 |
17 |
18 |
19 | Project Spec 20 |

{moonSpec.project}

21 |

{moonSpec.summary}

22 | 23 | Instrument 24 | {moonSpec.instrument} 25 | 26 |
27 | 28 |
29 |
30 |

Data & Timeframe

31 |
    32 |
  • Source: {moonSpec.dataFeed.source}
  • 33 |
  • Fields: {moonSpec.dataFeed.fields.join(", ")}
  • 34 |
  • Virtual bars: {moonSpec.dataFeed.timeframe}
  • 35 |
  • Synthetic close drives indicators and decisions.
  • 36 |
37 |
38 | 39 |
40 |

Indicator Stack

41 |

Exact values ported from the TradingView script:

42 |
    43 | {configEntries.map((entry) => ( 44 |
  • 45 | {entry.label}: {entry.value} 46 |
  • 47 | ))} 48 |
49 |

Bull stack = fast > mid > slow. Bear stack mirrors it. RSI filters gate the edge-triggered entries.

50 |
51 | 52 |
53 |

Watermellon Triggers

54 |
    55 |
  • longLook = bull stack AND RSI > 42
  • 56 |
  • shortLook = bear stack AND RSI < 58
  • 57 |
  • Signals fire only when look flips from false → true.
  • 58 |
  • Flat → enter on trigger. Long ↔ short flips close then optionally reverse.
  • 59 |
60 |
61 | 62 |
63 |

Risk Envelope

64 |
    65 | {moonSpec.risk.map((item) => ( 66 |
  • {item}
  • 67 | ))} 68 |
69 |
70 |
71 | 72 |
73 |
74 |

Runtime

75 |

76 | Modes: {moonSpec.runtime.modes.join(" + ")}. Logs: {moonSpec.runtime.logging.join(", ")}. Requirements:{" "} 77 | {moonSpec.runtime.requirements.join("; ")}. 78 |

79 |
80 | 81 |
82 |

Trading Rules

83 |

{moonSpec.tradingRules.join(" ")}

84 |
85 |
86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type Tick = { 2 | timestamp: number; 3 | price: number; 4 | size?: number; 5 | }; 6 | 7 | export type SyntheticBar = { 8 | startTime: number; 9 | endTime: number; 10 | open: number; 11 | high: number; 12 | low: number; 13 | close: number; 14 | volume: number; 15 | }; 16 | 17 | export type IndicatorSnapshot = { 18 | emaFast: number; 19 | emaMid: number; 20 | emaSlow: number; 21 | rsi: number; 22 | }; 23 | 24 | export type TrendSnapshot = { 25 | bullStack: boolean; 26 | bearStack: boolean; 27 | longLook: boolean; 28 | shortLook: boolean; 29 | longTrig: boolean; 30 | shortTrig: boolean; 31 | }; 32 | 33 | export type StrategySignal = 34 | | { 35 | type: "long"; 36 | reason: "long-trigger" | "v1-long" | "v2-long"; 37 | indicators: IndicatorSnapshot; 38 | trend: TrendSnapshot; 39 | system?: "v1" | "v2"; 40 | } 41 | | { 42 | type: "short"; 43 | reason: "short-trigger" | "v1-short" | "v2-short"; 44 | indicators: IndicatorSnapshot; 45 | trend: TrendSnapshot; 46 | system?: "v1" | "v2"; 47 | } 48 | | null; 49 | 50 | export type ExitSignal = { 51 | reason: "rsi-flattening" | "volume-drop" | "opposite-signal" | "stop-loss" | "emergency-stop"; 52 | details?: Record; 53 | }; 54 | 55 | export type WatermellonConfig = { 56 | timeframeMs: number; 57 | emaFastLen: number; 58 | emaMidLen: number; 59 | emaSlowLen: number; 60 | rsiLength: number; 61 | rsiMinLong: number; 62 | rsiMaxShort: number; 63 | }; 64 | 65 | // Peach Hybrid Strategy Configuration 66 | export type PeachV1Config = { 67 | emaFastLen: number; // 8 68 | emaMidLen: number; // 21 69 | emaSlowLen: number; // 48 70 | emaMicroFastLen: number; // 5 71 | emaMicroSlowLen: number; // 13 72 | rsiLength: number; // 14 73 | rsiMinLong: number; // 42.0 74 | rsiMaxShort: number; // 58.0 75 | minBarsBetween: number; // 1 76 | minMovePercent: number; // 0.10 77 | }; 78 | 79 | export type PeachV2Config = { 80 | emaFastLen: number; // 3 81 | emaMidLen: number; // 8 82 | emaSlowLen: number; // 13 83 | rsiMomentumThreshold: number; // 3.0 points in 2 bars 84 | volumeLookback: number; // 4 candles 85 | volumeMultiplier: number; // 1.5x average 86 | exitVolumeMultiplier: number; // 1.2x average 87 | }; 88 | 89 | export type PeachConfig = { 90 | timeframeMs: number; 91 | v1: PeachV1Config; 92 | v2: PeachV2Config; 93 | }; 94 | 95 | export type RiskConfig = { 96 | maxPositionSize: number; 97 | maxLeverage: number; 98 | maxFlipsPerHour: number; 99 | stopLossPct?: number; 100 | takeProfitPct?: number; 101 | // Peach Hybrid Risk Management 102 | useStopLoss?: boolean; // false 103 | emergencyStopLoss?: number; // 2.0% 104 | maxPositions?: number; // 1 at a time 105 | // Position sizing 106 | positionSizePct?: number; // Percentage of available balance (0-100), overrides maxPositionSize if set 107 | // Market regime filters 108 | requireTrendingMarket?: boolean; // Only trade when market is trending (ADX > threshold) 109 | adxThreshold?: number; // ADX threshold for trending market (default: 25) 110 | }; 111 | 112 | export type Mode = "dry-run" | "live"; 113 | 114 | export type Credentials = { 115 | rpcUrl: string; 116 | wsUrl: string; 117 | apiKey: string; 118 | apiSecret: string; 119 | privateKey: string; 120 | pairSymbol: string; 121 | }; 122 | 123 | export type AppConfig = { 124 | mode: Mode; 125 | credentials: Credentials; 126 | strategy: WatermellonConfig | PeachConfig; 127 | risk: RiskConfig; 128 | strategyType?: "watermellon" | "peach-hybrid"; 129 | }; 130 | 131 | export type PositionSide = "long" | "short" | "flat"; 132 | 133 | export type PositionState = { 134 | side: PositionSide; 135 | size: number; 136 | entryPrice?: number; 137 | openedAt?: number; 138 | }; 139 | 140 | export type TradeInstruction = { 141 | side: Exclude; 142 | size: number; 143 | leverage: number; 144 | price: number; 145 | signalReason: string; 146 | timestamp: number; 147 | }; 148 | 149 | export type ExecutionAdapter = { 150 | enterLong(order: TradeInstruction): Promise; 151 | enterShort(order: TradeInstruction): Promise; 152 | closePosition(reason: string, meta?: Record): Promise; 153 | }; 154 | 155 | -------------------------------------------------------------------------------- /src/lib/indicators/adx.ts: -------------------------------------------------------------------------------- 1 | export class ADX { 2 | private readonly length: number; 3 | private trValues: number[] = []; 4 | private plusDMValues: number[] = []; 5 | private minusDMValues: number[] = []; 6 | private dxValues: number[] = []; 7 | private prevHigh: number | null = null; 8 | private prevLow: number | null = null; 9 | private prevClose: number | null = null; 10 | private prevATR: number | null = null; 11 | private prevPlusDI: number | null = null; 12 | private prevMinusDI: number | null = null; 13 | private adxValue: number | null = null; 14 | private updateCount = 0; 15 | 16 | constructor(length: number = 14) { 17 | if (length < 2) { 18 | throw new Error("ADX length must be at least 2"); 19 | } 20 | this.length = length; 21 | } 22 | 23 | update(high: number, low: number, close: number): number | null { 24 | if (this.prevHigh === null || this.prevLow === null || this.prevClose === null) { 25 | this.prevHigh = high; 26 | this.prevLow = low; 27 | this.prevClose = close; 28 | return null; 29 | } 30 | 31 | this.updateCount++; 32 | 33 | // Calculate True Range 34 | const tr = Math.max( 35 | high - low, 36 | Math.abs(high - this.prevClose), 37 | Math.abs(low - this.prevClose) 38 | ); 39 | 40 | // Calculate Directional Movement 41 | const plusDM = (high > this.prevHigh && low > this.prevLow) 42 | ? Math.max(high - this.prevHigh, 0) 43 | : 0; 44 | 45 | const minusDM = (this.prevHigh > high && this.prevLow > low) 46 | ? Math.max(this.prevLow - low, 0) 47 | : 0; 48 | 49 | // Store values for initial calculation 50 | this.trValues.push(tr); 51 | this.plusDMValues.push(plusDM); 52 | this.minusDMValues.push(minusDM); 53 | 54 | // Keep only enough history for initial calculation 55 | if (this.trValues.length > this.length) { 56 | this.trValues.shift(); 57 | this.plusDMValues.shift(); 58 | this.minusDMValues.shift(); 59 | } 60 | 61 | // Need enough data for initial calculation 62 | if (this.updateCount < this.length + 1) { 63 | this.prevHigh = high; 64 | this.prevLow = low; 65 | this.prevClose = close; 66 | return null; 67 | } 68 | 69 | let atr: number; 70 | let plusDI: number; 71 | let minusDI: number; 72 | 73 | if (this.updateCount === this.length + 1) { 74 | // First calculation - use simple averages 75 | atr = this.trValues.reduce((sum, val) => sum + val, 0) / this.length; 76 | const avgPlusDM = this.plusDMValues.reduce((sum, val) => sum + val, 0) / this.length; 77 | const avgMinusDM = this.minusDMValues.reduce((sum, val) => sum + val, 0) / this.length; 78 | 79 | plusDI = avgPlusDM / atr * 100; 80 | minusDI = avgMinusDM / atr * 100; 81 | } else { 82 | // Subsequent calculations - use Wilder's smoothing 83 | const alpha = 1 / this.length; 84 | atr = this.prevATR! * (1 - alpha) + tr * alpha; 85 | const plusDM_smooth = this.prevPlusDI! / 100 * this.prevATR! * (1 - alpha) + plusDM * alpha; 86 | const minusDM_smooth = this.prevMinusDI! / 100 * this.prevATR! * (1 - alpha) + minusDM * alpha; 87 | 88 | plusDI = plusDM_smooth / atr * 100; 89 | minusDI = minusDM_smooth / atr * 100; 90 | } 91 | 92 | // Calculate DX 93 | const dx = Math.abs(plusDI - minusDI) / (plusDI + minusDI) * 100; 94 | 95 | // Calculate ADX 96 | if (this.updateCount === this.length + 1) { 97 | // First ADX value - use simple average of first DX values 98 | this.dxValues.push(dx); 99 | if (this.dxValues.length >= this.length) { 100 | this.adxValue = this.dxValues.reduce((sum, val) => sum + val, 0) / this.dxValues.length; 101 | } 102 | } else if (this.adxValue !== null) { 103 | // Subsequent ADX values - use Wilder's smoothing 104 | const alpha = 1 / this.length; 105 | this.adxValue = this.adxValue * (1 - alpha) + dx * alpha; 106 | } 107 | 108 | // Store previous values 109 | this.prevATR = atr; 110 | this.prevPlusDI = plusDI; 111 | this.prevMinusDI = minusDI; 112 | 113 | this.prevHigh = high; 114 | this.prevLow = low; 115 | this.prevClose = close; 116 | 117 | return this.adxValue; 118 | } 119 | 120 | get value(): number | null { 121 | return this.adxValue; 122 | } 123 | 124 | get isReady(): boolean { 125 | return this.adxValue !== null; 126 | } 127 | 128 | // Helper to determine if market is trending 129 | isTrending(threshold: number = 25): boolean { 130 | return this.adxValue !== null && this.adxValue > threshold; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/lib/state/positionState.ts: -------------------------------------------------------------------------------- 1 | export type LocalPositionState = { 2 | size: number; 3 | side: "long" | "short" | "flat"; 4 | avgEntry: number; 5 | unrealizedPnl: number; 6 | lastUpdate: number; 7 | orderId?: string; 8 | pendingOrder?: { 9 | side: "long" | "short"; 10 | size: number; 11 | timestamp: number; 12 | }; 13 | }; 14 | 15 | export class PositionStateManager { 16 | private state: LocalPositionState = { 17 | size: 0, 18 | side: "flat", 19 | avgEntry: 0, 20 | unrealizedPnl: 0, 21 | lastUpdate: Date.now(), 22 | }; 23 | 24 | private reconciliationFailures = 0; 25 | private readonly maxReconciliationFailures = 2; 26 | 27 | updateLocalState(update: Partial): void { 28 | this.state = { 29 | ...this.state, 30 | ...update, 31 | lastUpdate: Date.now(), 32 | }; 33 | } 34 | 35 | updateFromRest(restState: { 36 | positionAmt: string; 37 | entryPrice: string; 38 | unrealizedProfit: string; 39 | }): boolean { 40 | const size = parseFloat(restState.positionAmt); 41 | const side: "long" | "short" | "flat" = size > 0 ? "long" : size < 0 ? "short" : "flat"; 42 | const avgEntry = parseFloat(restState.entryPrice) || 0; 43 | const unrealizedPnl = parseFloat(restState.unrealizedProfit) || 0; 44 | 45 | const restStateNormalized = { 46 | size: Math.abs(size), 47 | side, 48 | avgEntry, 49 | unrealizedPnl, 50 | }; 51 | 52 | const localStateNormalized = { 53 | size: this.state.size, 54 | side: this.state.side, 55 | avgEntry: this.state.avgEntry, 56 | unrealizedPnl: this.state.unrealizedPnl, 57 | }; 58 | 59 | const reconciled = this.reconcile(restStateNormalized, localStateNormalized); 60 | 61 | if (reconciled) { 62 | this.reconciliationFailures = 0; 63 | this.state = { 64 | ...this.state, 65 | ...restStateNormalized, 66 | lastUpdate: Date.now(), 67 | }; 68 | return true; 69 | } 70 | 71 | // If REST says flat but local has a position, trust REST (position was closed externally) 72 | // This handles stale state from previous runs 73 | if (restStateNormalized.side === "flat" && localStateNormalized.side !== "flat") { 74 | console.log(`[PositionState] REST shows flat position, clearing local state (was ${localStateNormalized.side} ${localStateNormalized.size})`); 75 | this.reconciliationFailures = 0; 76 | this.state = { 77 | ...this.state, 78 | ...restStateNormalized, 79 | lastUpdate: Date.now(), 80 | }; 81 | return true; 82 | } 83 | 84 | // If REST has a position but local is flat, trust REST (position exists on exchange) 85 | // This handles cases where bot restarted or position was opened externally 86 | if (restStateNormalized.side !== "flat" && localStateNormalized.side === "flat") { 87 | console.log(`[PositionState] REST shows ${restStateNormalized.side} position (${restStateNormalized.size}), updating local state from flat`); 88 | this.reconciliationFailures = 0; 89 | this.state = { 90 | ...this.state, 91 | ...restStateNormalized, 92 | lastUpdate: Date.now(), 93 | }; 94 | return true; 95 | } 96 | 97 | this.reconciliationFailures++; 98 | return false; 99 | } 100 | 101 | private reconcile( 102 | rest: { size: number; side: "long" | "short" | "flat"; avgEntry: number; unrealizedPnl: number }, 103 | local: { size: number; side: "long" | "short" | "flat"; avgEntry: number; unrealizedPnl: number }, 104 | ): boolean { 105 | const sizeMatch = Math.abs(rest.size - local.size) < 0.0001; 106 | const sideMatch = rest.side === local.side; 107 | 108 | // If both are flat, entry price doesn't matter (should be 0 anyway) 109 | if (rest.side === "flat" && local.side === "flat") { 110 | return sizeMatch && sideMatch; // Only check size and side when both flat 111 | } 112 | 113 | // For non-flat positions, check entry price match 114 | const entryMatch = rest.avgEntry === 0 || Math.abs(rest.avgEntry - local.avgEntry) / rest.avgEntry < 0.01; 115 | 116 | return sizeMatch && sideMatch && entryMatch; 117 | } 118 | 119 | shouldFreezeTrading(): boolean { 120 | return this.reconciliationFailures >= this.maxReconciliationFailures; 121 | } 122 | 123 | resetReconciliationFailures(): void { 124 | this.reconciliationFailures = 0; 125 | } 126 | 127 | getState(): LocalPositionState { 128 | return { ...this.state }; 129 | } 130 | 131 | clearPendingOrder(): void { 132 | this.state.pendingOrder = undefined; 133 | } 134 | 135 | setPendingOrder(order: { side: "long" | "short"; size: number; timestamp: number }): void { 136 | this.state.pendingOrder = order; 137 | } 138 | } 139 | 140 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { defaultWatermellonConfig } from "./spec"; 3 | import type { AppConfig, Mode, PeachConfig, RiskConfig, WatermellonConfig } from "./types"; 4 | 5 | const envSchema = z.object({ 6 | ASTER_RPC_URL: z.string().url(), 7 | ASTER_WS_URL: z.string().url(), 8 | ASTER_API_KEY: z.string().min(1, "API key is required"), 9 | ASTER_API_SECRET: z.string().min(1, "API secret is required"), 10 | ASTER_PRIVATE_KEY: z.string().min(1, "Private key is required"), 11 | PAIR_SYMBOL: z.string().min(1, "Trading pair is required"), 12 | MAX_POSITION_USDT: z.coerce.number().positive(), 13 | MAX_LEVERAGE: z.coerce.number().positive(), 14 | MAX_FLIPS_PER_HOUR: z.coerce.number().int().nonnegative(), 15 | STOP_LOSS_PCT: z.coerce.number().optional(), 16 | TAKE_PROFIT_PCT: z.coerce.number().optional(), 17 | POSITION_SIZE_PCT: z.coerce.number().min(0).max(100).optional(), 18 | REQUIRE_TRENDING_MARKET: z.coerce.boolean().optional(), 19 | ADX_THRESHOLD: z.coerce.number().min(0).max(100).optional(), 20 | MODE: z.enum(["dry-run", "live"]), 21 | STRATEGY_TYPE: z.enum(["watermellon", "peach-hybrid"]).optional(), 22 | VIRTUAL_TIMEFRAME_MS: z.coerce.number().optional(), 23 | // Watermellon params 24 | EMA_FAST: z.coerce.number().optional(), 25 | EMA_MID: z.coerce.number().optional(), 26 | EMA_SLOW: z.coerce.number().optional(), 27 | RSI_LENGTH: z.coerce.number().optional(), 28 | RSI_MIN_LONG: z.coerce.number().optional(), 29 | RSI_MAX_SHORT: z.coerce.number().optional(), 30 | // Peach Hybrid V1 params 31 | PEACH_V1_EMA_FAST: z.coerce.number().optional(), 32 | PEACH_V1_EMA_MID: z.coerce.number().optional(), 33 | PEACH_V1_EMA_SLOW: z.coerce.number().optional(), 34 | PEACH_V1_EMA_MICRO_FAST: z.coerce.number().optional(), 35 | PEACH_V1_EMA_MICRO_SLOW: z.coerce.number().optional(), 36 | PEACH_V1_RSI_LENGTH: z.coerce.number().optional(), 37 | PEACH_V1_RSI_MIN_LONG: z.coerce.number().optional(), 38 | PEACH_V1_RSI_MAX_SHORT: z.coerce.number().optional(), 39 | PEACH_V1_MIN_BARS_BETWEEN: z.coerce.number().optional(), 40 | PEACH_V1_MIN_MOVE_PCT: z.coerce.number().optional(), 41 | // Peach Hybrid V2 params 42 | PEACH_V2_EMA_FAST: z.coerce.number().optional(), 43 | PEACH_V2_EMA_MID: z.coerce.number().optional(), 44 | PEACH_V2_EMA_SLOW: z.coerce.number().optional(), 45 | PEACH_V2_RSI_MOMENTUM_THRESHOLD: z.coerce.number().optional(), 46 | PEACH_V2_VOLUME_LOOKBACK: z.coerce.number().optional(), 47 | PEACH_V2_VOLUME_MULTIPLIER: z.coerce.number().optional(), 48 | PEACH_V2_EXIT_VOLUME_MULTIPLIER: z.coerce.number().optional(), 49 | // Risk management 50 | USE_STOP_LOSS: z.coerce.boolean().optional(), 51 | EMERGENCY_STOP_LOSS_PCT: z.coerce.number().optional(), 52 | MAX_POSITIONS: z.coerce.number().optional(), 53 | }); 54 | 55 | const formatErrors = (issues: z.ZodIssue[]): string => 56 | issues.map((i) => `${i.path.join(".") || "env"}: ${i.message}`).join("; "); 57 | 58 | export const loadConfig = (overrides?: Partial): AppConfig => { 59 | const parsed = envSchema.safeParse(process.env); 60 | if (!parsed.success) { 61 | throw new Error(`Invalid environment configuration: ${formatErrors(parsed.error.issues)}`); 62 | } 63 | 64 | const env = parsed.data; 65 | const strategyType = env.STRATEGY_TYPE ?? "watermellon"; 66 | 67 | let strategy: WatermellonConfig | PeachConfig; 68 | 69 | if (strategyType === "peach-hybrid") { 70 | // Peach Hybrid Strategy 71 | strategy = { 72 | timeframeMs: env.VIRTUAL_TIMEFRAME_MS ?? 30_000, 73 | v1: { 74 | emaFastLen: env.PEACH_V1_EMA_FAST ?? 8, 75 | emaMidLen: env.PEACH_V1_EMA_MID ?? 21, 76 | emaSlowLen: env.PEACH_V1_EMA_SLOW ?? 48, 77 | emaMicroFastLen: env.PEACH_V1_EMA_MICRO_FAST ?? 5, 78 | emaMicroSlowLen: env.PEACH_V1_EMA_MICRO_SLOW ?? 13, 79 | rsiLength: env.PEACH_V1_RSI_LENGTH ?? 14, 80 | rsiMinLong: env.PEACH_V1_RSI_MIN_LONG ?? 42.0, 81 | rsiMaxShort: env.PEACH_V1_RSI_MAX_SHORT ?? 58.0, 82 | minBarsBetween: env.PEACH_V1_MIN_BARS_BETWEEN ?? 1, 83 | minMovePercent: env.PEACH_V1_MIN_MOVE_PCT ?? 0.10, 84 | }, 85 | v2: { 86 | emaFastLen: env.PEACH_V2_EMA_FAST ?? 3, 87 | emaMidLen: env.PEACH_V2_EMA_MID ?? 8, 88 | emaSlowLen: env.PEACH_V2_EMA_SLOW ?? 13, 89 | rsiMomentumThreshold: env.PEACH_V2_RSI_MOMENTUM_THRESHOLD ?? 3.0, 90 | volumeLookback: env.PEACH_V2_VOLUME_LOOKBACK ?? 4, 91 | volumeMultiplier: env.PEACH_V2_VOLUME_MULTIPLIER ?? 1.5, 92 | exitVolumeMultiplier: env.PEACH_V2_EXIT_VOLUME_MULTIPLIER ?? 1.2, 93 | }, 94 | }; 95 | } else { 96 | // Watermellon Strategy (default) 97 | strategy = { 98 | ...defaultWatermellonConfig, 99 | timeframeMs: env.VIRTUAL_TIMEFRAME_MS ?? defaultWatermellonConfig.timeframeMs, 100 | emaFastLen: env.EMA_FAST ?? defaultWatermellonConfig.emaFastLen, 101 | emaMidLen: env.EMA_MID ?? defaultWatermellonConfig.emaMidLen, 102 | emaSlowLen: env.EMA_SLOW ?? defaultWatermellonConfig.emaSlowLen, 103 | rsiLength: env.RSI_LENGTH ?? defaultWatermellonConfig.rsiLength, 104 | rsiMinLong: env.RSI_MIN_LONG ?? defaultWatermellonConfig.rsiMinLong, 105 | rsiMaxShort: env.RSI_MAX_SHORT ?? defaultWatermellonConfig.rsiMaxShort, 106 | }; 107 | } 108 | 109 | const risk: RiskConfig = { 110 | maxPositionSize: env.MAX_POSITION_USDT, 111 | maxLeverage: env.MAX_LEVERAGE, 112 | maxFlipsPerHour: env.MAX_FLIPS_PER_HOUR, 113 | stopLossPct: env.STOP_LOSS_PCT ?? undefined, 114 | takeProfitPct: env.TAKE_PROFIT_PCT ?? undefined, 115 | useStopLoss: env.USE_STOP_LOSS ?? false, 116 | emergencyStopLoss: env.EMERGENCY_STOP_LOSS_PCT ?? 2.0, 117 | maxPositions: env.MAX_POSITIONS ?? 1, 118 | positionSizePct: env.POSITION_SIZE_PCT ?? undefined, 119 | requireTrendingMarket: env.REQUIRE_TRENDING_MARKET ?? false, 120 | adxThreshold: env.ADX_THRESHOLD ?? 25, 121 | }; 122 | 123 | const config: AppConfig = { 124 | mode: env.MODE as Mode, 125 | strategyType: strategyType as "watermellon" | "peach-hybrid", 126 | credentials: { 127 | rpcUrl: env.ASTER_RPC_URL, 128 | wsUrl: env.ASTER_WS_URL, 129 | apiKey: env.ASTER_API_KEY, 130 | apiSecret: env.ASTER_API_SECRET, 131 | privateKey: env.ASTER_PRIVATE_KEY, 132 | pairSymbol: env.PAIR_SYMBOL, 133 | }, 134 | strategy, 135 | risk, 136 | }; 137 | 138 | return overrides ? mergeConfig(config, overrides) : config; 139 | }; 140 | 141 | const mergeConfig = (base: AppConfig, overrides: Partial): AppConfig => ({ 142 | ...base, 143 | ...overrides, 144 | credentials: { ...base.credentials, ...overrides?.credentials }, 145 | strategy: { ...base.strategy, ...overrides?.strategy }, 146 | risk: { ...base.risk, ...overrides?.risk }, 147 | }); 148 | 149 | -------------------------------------------------------------------------------- /src/lib/tickStream.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "events"; 2 | import WebSocket from "ws"; 3 | import type { Tick } from "./types"; 4 | 5 | type TickEvents = { 6 | tick: (tick: Tick) => void; 7 | error: (error: Error) => void; 8 | close: () => void; 9 | }; 10 | 11 | // AsterDEX WebSocket format: Based on reference code, uses Binance-compatible format 12 | // Reference shows: wss://fstream.asterdex.com/ws with CCXT handling subscription 13 | // For raw WebSocket, we need to match Binance futures stream format 14 | const defaultSubscribePayload = (pair: string) => { 15 | // Convert ASTERUSDT-PERP to ASTERUSDT (remove -PERP suffix, uppercase) 16 | const streamName = pair.toUpperCase().replace(/-PERP$/, ""); 17 | // Binance futures format: lowercase symbol@aggTrade 18 | return { 19 | method: "SUBSCRIBE", 20 | params: [`${streamName.toLowerCase()}@aggTrade`], 21 | id: 1, 22 | }; 23 | }; 24 | 25 | type MessageParser = (raw: WebSocket.RawData) => Tick[] | null; 26 | 27 | const coerceNumber = (value: unknown): number | null => { 28 | if (typeof value === "number") { 29 | return Number.isFinite(value) ? value : null; 30 | } 31 | if (typeof value === "string") { 32 | const parsed = Number(value); 33 | return Number.isFinite(parsed) ? parsed : null; 34 | } 35 | return null; 36 | }; 37 | 38 | const defaultParser: MessageParser = (raw) => { 39 | try { 40 | const payload = JSON.parse(raw.toString()); 41 | 42 | // AsterDEX WebSocket format: { stream: "asterusdtperp@aggTrade", data: { ... } } 43 | if (payload.stream && payload.data) { 44 | const trade = payload.data; 45 | // AsterDEX format: { e: "aggTrade", p: "price", q: "quantity", T: timestamp, ... } 46 | const price = coerceNumber(trade.p); 47 | const size = coerceNumber(trade.q); 48 | const timestamp = coerceNumber(trade.T) ?? Date.now(); 49 | 50 | if (price === null) return null; 51 | 52 | return [{ price, size: size ?? 0, timestamp }]; 53 | } 54 | 55 | // Fallback: try to parse as direct trade object 56 | if (payload.p || payload.price) { 57 | const price = coerceNumber(payload.p) ?? coerceNumber(payload.price); 58 | const size = coerceNumber(payload.q) ?? coerceNumber(payload.quantity); 59 | const timestamp = coerceNumber(payload.T) ?? coerceNumber(payload.timestamp) ?? Date.now(); 60 | 61 | if (price === null) return null; 62 | 63 | return [{ price, size: size ?? 0, timestamp }]; 64 | } 65 | 66 | return null; 67 | } catch (error) { 68 | console.error("Failed to parse tick message", error); 69 | return null; 70 | } 71 | }; 72 | 73 | export class AsterTickStream { 74 | private readonly emitter = new EventEmitter(); 75 | private ws: WebSocket | null = null; 76 | private heartbeatInterval: NodeJS.Timeout | null = null; 77 | private lastMessageTime = 0; 78 | private reconnectTimeout: NodeJS.Timeout | null = null; 79 | private readonly heartbeatIntervalMs = 10_000; // 10 seconds 80 | private readonly wsTimeoutMs = 5_000; // 5 seconds 81 | private reconnectAttempts = 0; 82 | private readonly maxReconnectAttempts = 10; 83 | 84 | constructor( 85 | private readonly url: string, 86 | private readonly pairSymbol: string, 87 | private readonly parser: MessageParser = defaultParser, 88 | private readonly subscribePayloadBuilder: (pair: string) => unknown = defaultSubscribePayload, 89 | ) {} 90 | 91 | async start(): Promise { 92 | await this.stop(); 93 | console.log(`[TickStream] Connecting to ${this.url}...`); 94 | // AsterDEX uses wss://fstream.asterdex.com/ws (reference code shows this) 95 | // Ensure we're using the correct base URL 96 | const wsUrl = this.url.endsWith("/ws") ? this.url : `${this.url}/ws`; 97 | this.ws = new WebSocket(wsUrl, { 98 | headers: { 99 | "User-Agent": "Watermellon-bot/0.1", 100 | }, 101 | }); 102 | 103 | this.ws.on("open", () => { 104 | console.log(`[TickStream] WebSocket connected to ${this.url}`); 105 | this.reconnectAttempts = 0; 106 | this.lastMessageTime = Date.now(); 107 | this.startHeartbeat(); 108 | const payload = this.subscribePayloadBuilder(this.pairSymbol); 109 | console.log(`[TickStream] Subscribing to:`, JSON.stringify(payload, null, 2)); 110 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { 111 | this.ws.send(JSON.stringify(payload)); 112 | } else { 113 | console.error(`[TickStream] WebSocket not ready, state: ${this.ws?.readyState}`); 114 | } 115 | }); 116 | 117 | this.ws.on("message", (raw) => { 118 | this.lastMessageTime = Date.now(); 119 | const message = raw.toString(); 120 | 121 | // Check if this is a subscription confirmation or ping response 122 | try { 123 | const parsed = JSON.parse(message); 124 | if (parsed.result === null || parsed.id) { 125 | console.log(`[TickStream] Subscription confirmed`); 126 | return; 127 | } 128 | if (parsed.error) { 129 | console.error(`[TickStream] Subscription error:`, parsed.error); 130 | return; 131 | } 132 | // Handle ping/pong 133 | if (parsed.ping || parsed.pong) { 134 | return; 135 | } 136 | } catch { 137 | // Not JSON, continue to parse as trade data 138 | } 139 | 140 | const ticks = this.parser(raw); 141 | if (!ticks || ticks.length === 0) { 142 | return; 143 | } 144 | ticks.forEach((tick) => { 145 | this.emitter.emit("tick", tick); 146 | }); 147 | }); 148 | 149 | this.ws.on("error", (err) => { 150 | console.error(`[TickStream] WebSocket error:`, err); 151 | this.emitter.emit("error", err as Error); 152 | }); 153 | 154 | this.ws.on("close", (code, reason) => { 155 | console.log(`[TickStream] WebSocket closed: code=${code}, reason=${reason.toString()}`); 156 | this.stopHeartbeat(); 157 | this.scheduleReconnect(); 158 | this.emitter.emit("close"); 159 | }); 160 | } 161 | 162 | private startHeartbeat(): void { 163 | this.stopHeartbeat(); 164 | this.heartbeatInterval = setInterval(() => { 165 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { 166 | // Send ping 167 | this.ws.ping(); 168 | // Check if we've received messages recently 169 | const timeSinceLastMessage = Date.now() - this.lastMessageTime; 170 | if (timeSinceLastMessage > this.wsTimeoutMs) { 171 | console.warn(`[TickStream] No messages for ${timeSinceLastMessage}ms, reconnecting...`); 172 | this.reconnect(); 173 | } 174 | } 175 | }, this.heartbeatIntervalMs); 176 | } 177 | 178 | private stopHeartbeat(): void { 179 | if (this.heartbeatInterval) { 180 | clearInterval(this.heartbeatInterval); 181 | this.heartbeatInterval = null; 182 | } 183 | } 184 | 185 | private scheduleReconnect(): void { 186 | if (this.reconnectTimeout) { 187 | return; 188 | } 189 | if (this.reconnectAttempts >= this.maxReconnectAttempts) { 190 | console.error(`[TickStream] Max reconnect attempts reached`); 191 | return; 192 | } 193 | const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); 194 | console.log(`[TickStream] Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts + 1})`); 195 | this.reconnectTimeout = setTimeout(() => { 196 | this.reconnectTimeout = null; 197 | this.reconnect(); 198 | }, delay); 199 | } 200 | 201 | private async reconnect(): Promise { 202 | this.reconnectAttempts++; 203 | console.log(`[TickStream] Reconnecting... (attempt ${this.reconnectAttempts})`); 204 | await this.stop(); 205 | await this.start(); 206 | } 207 | 208 | async stop(): Promise { 209 | this.stopHeartbeat(); 210 | if (this.reconnectTimeout) { 211 | clearTimeout(this.reconnectTimeout); 212 | this.reconnectTimeout = null; 213 | } 214 | await new Promise((resolve) => { 215 | if (!this.ws) { 216 | return resolve(); 217 | } 218 | this.ws.once("close", () => resolve()); 219 | this.ws.close(); 220 | this.ws = null; 221 | }); 222 | } 223 | 224 | on(event: K, handler: TickEvents[K]): () => void { 225 | this.emitter.on(event, handler as (...args: unknown[]) => void); 226 | return () => this.emitter.off(event, handler as (...args: unknown[]) => void); 227 | } 228 | } 229 | 230 | -------------------------------------------------------------------------------- /test-bot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Comprehensive Bot Testing Suite 3 | * Tests all major components of the trading bot 4 | */ 5 | 6 | import { EMA } from "./src/lib/indicators/ema"; 7 | import { RSI } from "./src/lib/indicators/rsi"; 8 | import { VirtualBarBuilder } from "./src/lib/virtualBarBuilder"; 9 | import { WatermellonEngine } from "./src/lib/watermellonEngine"; 10 | import { PeachHybridEngine } from "./src/lib/peachHybridEngine"; 11 | import { DryRunExecutor } from "./src/lib/execution/dryRunExecutor"; 12 | import { loadConfig } from "./src/lib/config"; 13 | import type { Tick, SyntheticBar, WatermellonConfig, PeachConfig } from "./src/lib/types"; 14 | 15 | console.log("=".repeat(80)); 16 | console.log("BOT TESTING SUITE"); 17 | console.log("=".repeat(80)); 18 | 19 | // Test 1: EMA Indicator 20 | console.log("\n[TEST 1] Testing EMA Indicator..."); 21 | try { 22 | const ema = new EMA(5); 23 | const values = [100, 101, 102, 103, 104, 105]; 24 | values.forEach((v, i) => { 25 | const result = ema.update(v); 26 | console.log(` EMA(${i + 1}): ${result.toFixed(4)}`); 27 | }); 28 | console.log("✅ EMA test passed"); 29 | } catch (error) { 30 | console.error("❌ EMA test failed:", error); 31 | } 32 | 33 | // Test 2: RSI Indicator 34 | console.log("\n[TEST 2] Testing RSI Indicator..."); 35 | try { 36 | const rsi = new RSI(14); 37 | const values = [100, 101, 102, 103, 102, 101, 100, 99, 98, 97, 98, 99, 100, 101, 102, 103, 104]; 38 | values.forEach((v, i) => { 39 | const result = rsi.update(v); 40 | if (i > 0) { 41 | console.log(` RSI(${i}): ${result?.toFixed(2) ?? "null"}`); 42 | } 43 | }); 44 | console.log("✅ RSI test passed"); 45 | } catch (error) { 46 | console.error("❌ RSI test failed:", error); 47 | } 48 | 49 | // Test 3: Virtual Bar Builder 50 | console.log("\n[TEST 3] Testing Virtual Bar Builder..."); 51 | try { 52 | const builder = new VirtualBarBuilder(30000); // 30 second bars 53 | const now = Date.now(); 54 | const ticks: Tick[] = [ 55 | { timestamp: now, price: 100, size: 1 }, 56 | { timestamp: now + 5000, price: 101, size: 2 }, 57 | { timestamp: now + 10000, price: 102, size: 1.5 }, 58 | { timestamp: now + 15000, price: 103, size: 2 }, 59 | { timestamp: now + 20000, price: 104, size: 1 }, 60 | { timestamp: now + 25000, price: 105, size: 2 }, 61 | { timestamp: now + 30000, price: 106, size: 1 }, // This should close the bar 62 | ]; 63 | 64 | let barCount = 0; 65 | ticks.forEach((tick) => { 66 | const result = builder.pushTick(tick); 67 | if (result.closedBar) { 68 | barCount++; 69 | console.log(` Bar ${barCount} closed:`, { 70 | open: result.closedBar.open, 71 | high: result.closedBar.high, 72 | low: result.closedBar.low, 73 | close: result.closedBar.close, 74 | volume: result.closedBar.volume.toFixed(2), 75 | }); 76 | } 77 | }); 78 | console.log(`✅ Virtual Bar Builder test passed (${barCount} bars created)`); 79 | } catch (error) { 80 | console.error("❌ Virtual Bar Builder test failed:", error); 81 | } 82 | 83 | // Test 4: Watermellon Engine 84 | console.log("\n[TEST 4] Testing Watermellon Engine..."); 85 | try { 86 | const config: WatermellonConfig = { 87 | timeframeMs: 30000, 88 | emaFastLen: 8, 89 | emaMidLen: 21, 90 | emaSlowLen: 48, 91 | rsiLength: 14, 92 | rsiMinLong: 42, 93 | rsiMaxShort: 58, 94 | }; 95 | const engine = new WatermellonEngine(config); 96 | 97 | // Simulate price movement 98 | const prices = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115]; 99 | let signalCount = 0; 100 | prices.forEach((price) => { 101 | const signal = engine.update(price); 102 | if (signal) { 103 | signalCount++; 104 | console.log(` Signal ${signalCount} at price ${price}:`, signal.type, signal.reason); 105 | } 106 | }); 107 | console.log(`✅ Watermellon Engine test passed (${signalCount} signals generated)`); 108 | } catch (error) { 109 | console.error("❌ Watermellon Engine test failed:", error); 110 | } 111 | 112 | // Test 5: Peach Hybrid Engine 113 | console.log("\n[TEST 5] Testing Peach Hybrid Engine..."); 114 | try { 115 | const config: PeachConfig = { 116 | timeframeMs: 30000, 117 | v1: { 118 | emaFastLen: 8, 119 | emaMidLen: 21, 120 | emaSlowLen: 48, 121 | emaMicroFastLen: 5, 122 | emaMicroSlowLen: 13, 123 | rsiLength: 14, 124 | rsiMinLong: 42.0, 125 | rsiMaxShort: 58.0, 126 | minBarsBetween: 1, 127 | minMovePercent: 0.10, 128 | }, 129 | v2: { 130 | emaFastLen: 3, 131 | emaMidLen: 8, 132 | emaSlowLen: 13, 133 | rsiMomentumThreshold: 3.0, 134 | volumeLookback: 4, 135 | volumeMultiplier: 1.5, 136 | exitVolumeMultiplier: 1.2, 137 | }, 138 | }; 139 | const engine = new PeachHybridEngine(config); 140 | 141 | // Simulate bars with volume 142 | const bars: SyntheticBar[] = []; 143 | const baseTime = Date.now(); 144 | for (let i = 0; i < 20; i++) { 145 | const price = 100 + i * 0.5; 146 | bars.push({ 147 | startTime: baseTime + i * 30000, 148 | endTime: baseTime + (i + 1) * 30000, 149 | open: price - 0.1, 150 | high: price + 0.2, 151 | low: price - 0.2, 152 | close: price, 153 | volume: 10 + Math.random() * 5, 154 | }); 155 | } 156 | 157 | let signalCount = 0; 158 | bars.forEach((bar, i) => { 159 | const signal = engine.update(bar); 160 | if (signal) { 161 | signalCount++; 162 | console.log(` Signal ${signalCount} at bar ${i}:`, signal.type, signal.reason, signal.system); 163 | } 164 | }); 165 | console.log(`✅ Peach Hybrid Engine test passed (${signalCount} signals generated)`); 166 | } catch (error) { 167 | console.error("❌ Peach Hybrid Engine test failed:", error); 168 | } 169 | 170 | // Test 6: Dry Run Executor 171 | console.log("\n[TEST 6] Testing Dry Run Executor..."); 172 | (async () => { 173 | try { 174 | const executor = new DryRunExecutor(); 175 | const order = { 176 | side: "long" as const, 177 | size: 1000, 178 | leverage: 5, 179 | price: 100, 180 | signalReason: "test", 181 | timestamp: Date.now(), 182 | }; 183 | 184 | await executor.enterLong(order); 185 | await executor.closePosition("test-close"); 186 | 187 | const logs = executor.logs; 188 | console.log(` Executor logged ${logs.length} entries`); 189 | logs.forEach((log, i) => { 190 | console.log(` Log ${i + 1}:`, log.type); 191 | }); 192 | console.log("✅ Dry Run Executor test passed"); 193 | } catch (error) { 194 | console.error("❌ Dry Run Executor test failed:", error); 195 | } 196 | })(); 197 | 198 | // Test 7: Configuration Loading (without env vars - should fail gracefully) 199 | console.log("\n[TEST 7] Testing Configuration Loading..."); 200 | try { 201 | // This will fail without proper env vars, but we can test the structure 202 | console.log(" Testing config structure (will fail without env vars)..."); 203 | try { 204 | const config = loadConfig(); 205 | console.log(" Config loaded successfully"); 206 | console.log(" Mode:", config.mode); 207 | console.log(" Strategy Type:", config.strategyType); 208 | console.log(" Max Position Size:", config.risk.maxPositionSize); 209 | console.log("✅ Configuration Loading test passed"); 210 | } catch (error) { 211 | const err = error instanceof Error ? error : new Error(String(error)); 212 | if (err.message.includes("Invalid environment configuration")) { 213 | console.log(" ✅ Configuration validation working (expected failure without env vars)"); 214 | } else { 215 | throw error; 216 | } 217 | } 218 | } catch (error) { 219 | console.error("❌ Configuration Loading test failed:", error); 220 | } 221 | 222 | // Test 8: Type Safety Checks 223 | console.log("\n[TEST 8] Testing Type Safety..."); 224 | try { 225 | // Test that types are properly defined 226 | const testTick: Tick = { timestamp: Date.now(), price: 100, size: 1 }; 227 | const testBar: SyntheticBar = { 228 | startTime: Date.now(), 229 | endTime: Date.now() + 30000, 230 | open: 100, 231 | high: 101, 232 | low: 99, 233 | close: 100.5, 234 | volume: 10, 235 | }; 236 | console.log(" Tick type:", typeof testTick.price); 237 | console.log(" Bar type:", typeof testBar.close); 238 | console.log("✅ Type Safety test passed"); 239 | } catch (error) { 240 | console.error("❌ Type Safety test failed:", error); 241 | } 242 | 243 | console.log("\n" + "=".repeat(80)); 244 | console.log("TESTING COMPLETE"); 245 | console.log("=".repeat(80)); 246 | 247 | -------------------------------------------------------------------------------- /src/lib/execution/liveExecutor.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import type { Credentials, ExecutionAdapter, TradeInstruction } from "../types"; 3 | 4 | type AsterOrderResponse = { 5 | orderId: number; 6 | symbol: string; 7 | status: string; 8 | clientOrderId: string; 9 | price: string; 10 | avgPrice: string; 11 | origQty: string; 12 | executedQty: string; 13 | cumQuote: string; 14 | timeInForce: string; 15 | type: string; 16 | reduceOnly: boolean; 17 | closePosition: boolean; 18 | side: string; 19 | positionSide: string; 20 | stopPrice: string; 21 | workingType: string; 22 | priceProtect: boolean; 23 | origType: string; 24 | time: number; 25 | updateTime: number; 26 | }; 27 | 28 | type AsterErrorResponse = { 29 | code: number; 30 | msg: string; 31 | }; 32 | 33 | export class LiveExecutor implements ExecutionAdapter { 34 | private readonly baseUrl: string; 35 | private readonly apiKey: string; 36 | private readonly apiSecret: string; 37 | private readonly symbol: string; 38 | 39 | constructor(credentials: Credentials) { 40 | this.baseUrl = credentials.rpcUrl; 41 | this.apiKey = credentials.apiKey; 42 | this.apiSecret = credentials.apiSecret; 43 | // Normalize symbol: remove -PERP suffix and convert to uppercase for REST API 44 | this.symbol = this.normalizeSymbol(credentials.pairSymbol); 45 | } 46 | 47 | private normalizeSymbol(symbol: string): string { 48 | // AsterDEX REST API expects ASTERUSDT (not ASTERUSDT-PERP) 49 | return symbol.toUpperCase().replace(/-PERP$/, ""); 50 | } 51 | 52 | async enterLong(order: TradeInstruction): Promise { 53 | await this.setLeverage(order.leverage); 54 | // Try without positionSide first (one-way mode), fallback to hedge mode if needed 55 | try { 56 | await this.placeOrder({ 57 | side: "BUY", 58 | type: "MARKET", 59 | quantity: order.size, 60 | }); 61 | } catch (error) { 62 | // If one-way mode fails, try with positionSide (hedge mode) 63 | const err = error instanceof Error ? error.message : String(error); 64 | if (err.includes("position side")) { 65 | await this.placeOrder({ 66 | side: "BUY", 67 | type: "MARKET", 68 | quantity: order.size, 69 | positionSide: "LONG", 70 | }); 71 | } else { 72 | throw error; 73 | } 74 | } 75 | console.log(`[LiveExecutor] Entered LONG position: ${order.size} @ ${order.price}`); 76 | } 77 | 78 | async enterShort(order: TradeInstruction): Promise { 79 | await this.setLeverage(order.leverage); 80 | // Try without positionSide first (one-way mode), fallback to hedge mode if needed 81 | try { 82 | await this.placeOrder({ 83 | side: "SELL", 84 | type: "MARKET", 85 | quantity: order.size, 86 | }); 87 | } catch (error) { 88 | // If one-way mode fails, try with positionSide (hedge mode) 89 | const err = error instanceof Error ? error.message : String(error); 90 | if (err.includes("position side")) { 91 | await this.placeOrder({ 92 | side: "SELL", 93 | type: "MARKET", 94 | quantity: order.size, 95 | positionSide: "SHORT", 96 | }); 97 | } else { 98 | throw error; 99 | } 100 | } 101 | console.log(`[LiveExecutor] Entered SHORT position: ${order.size} @ ${order.price}`); 102 | } 103 | 104 | async closePosition(reason: string, meta?: Record): Promise { 105 | try { 106 | const position = await this.getCurrentPosition(); 107 | if (!position || position.positionAmt === "0") { 108 | console.log("[LiveExecutor] No position to close"); 109 | return; 110 | } 111 | 112 | const positionAmt = parseFloat(position.positionAmt); 113 | if (positionAmt === 0) { 114 | console.log("[LiveExecutor] Position amount is zero"); 115 | return; 116 | } 117 | 118 | // Determine side: positive = long (need to sell), negative = short (need to buy) 119 | const side = positionAmt > 0 ? "SELL" : "BUY"; 120 | const quantity = Math.abs(positionAmt); 121 | 122 | // Try without positionSide first (one-way mode), fallback to hedge mode if needed 123 | try { 124 | await this.placeOrder({ 125 | side, 126 | type: "MARKET", 127 | quantity, 128 | reduceOnly: true, 129 | }); 130 | } catch (error) { 131 | // If one-way mode fails, try with positionSide (hedge mode) 132 | const err = error instanceof Error ? error.message : String(error); 133 | if (err.includes("position side")) { 134 | const positionSide = positionAmt > 0 ? "LONG" : "SHORT"; 135 | await this.placeOrder({ 136 | side, 137 | type: "MARKET", 138 | quantity, 139 | positionSide, 140 | reduceOnly: true, 141 | }); 142 | } else { 143 | throw error; 144 | } 145 | } 146 | 147 | console.log(`[LiveExecutor] Closed position: ${reason}`, { positionAmt, side, quantity, ...meta }); 148 | } catch (error) { 149 | console.error(`[LiveExecutor] Failed to close position: ${reason}`, error); 150 | throw error; 151 | } 152 | } 153 | 154 | private async setLeverage(leverage: number): Promise { 155 | const params = new URLSearchParams({ 156 | symbol: this.symbol, 157 | leverage: leverage.toString(), 158 | }); 159 | 160 | await this.signedRequest("POST", "/fapi/v1/leverage", params); 161 | } 162 | 163 | private async placeOrder(params: { 164 | side: "BUY" | "SELL"; 165 | type: string; 166 | quantity: number; 167 | positionSide?: string; 168 | reduceOnly?: boolean; 169 | price?: number; 170 | }): Promise { 171 | const orderParams = new URLSearchParams({ 172 | symbol: this.symbol, 173 | side: params.side, 174 | type: params.type, 175 | quantity: params.quantity.toString(), 176 | }); 177 | 178 | if (params.positionSide) { 179 | orderParams.append("positionSide", params.positionSide); 180 | } 181 | if (params.reduceOnly) { 182 | orderParams.append("reduceOnly", "true"); 183 | } 184 | if (params.price) { 185 | orderParams.append("price", params.price.toString()); 186 | } 187 | 188 | const response = await this.signedRequest("POST", "/fapi/v1/order", orderParams); 189 | return response; 190 | } 191 | 192 | private async getCurrentPosition(): Promise<{ positionAmt: string; symbol: string; entryPrice?: string } | null> { 193 | try { 194 | // Use /fapi/v2/account endpoint like reference code (more reliable) 195 | const account = await this.signedRequest<{ positions: Array<{ positionAmt: string; symbol: string; entryPrice?: string }> }>( 196 | "GET", 197 | "/fapi/v2/account", 198 | new URLSearchParams(), 199 | ); 200 | const position = account.positions?.find((p) => p.symbol === this.symbol); 201 | if (position && position.positionAmt !== "0") { 202 | return position; 203 | } 204 | return null; 205 | } catch { 206 | // Fallback to positionRisk endpoint 207 | try { 208 | const params = new URLSearchParams({ 209 | symbol: this.symbol, 210 | }); 211 | const positions = await this.signedRequest>( 212 | "GET", 213 | "/fapi/v2/positionRisk", 214 | params, 215 | ); 216 | const position = positions.find((p) => p.symbol === this.symbol); 217 | if (position && position.positionAmt !== "0") { 218 | return position; 219 | } 220 | } catch (fallbackError) { 221 | console.error("[LiveExecutor] Failed to get position", fallbackError); 222 | } 223 | return null; 224 | } 225 | } 226 | 227 | private async signedRequest( 228 | method: "GET" | "POST" | "PUT" | "DELETE", 229 | endpoint: string, 230 | params: URLSearchParams, 231 | ): Promise { 232 | const timestamp = Date.now(); 233 | params.append("timestamp", timestamp.toString()); 234 | 235 | const queryString = params.toString(); 236 | const signature = this.generateSignature(queryString); 237 | 238 | const url = `${this.baseUrl}${endpoint}?${queryString}&signature=${signature}`; 239 | 240 | try { 241 | const response = await fetch(url, { 242 | method, 243 | headers: { 244 | "X-MBX-APIKEY": this.apiKey, 245 | "Content-Type": "application/json", 246 | }, 247 | }); 248 | 249 | if (!response.ok) { 250 | let errorMsg = `HTTP ${response.status}: ${response.statusText}`; 251 | let errorCode: number | undefined; 252 | try { 253 | const error: AsterErrorResponse = await response.json(); 254 | errorCode = error.code; 255 | errorMsg = `AsterDEX API error: ${error.code || response.status} - ${error.msg || response.statusText}`; 256 | } catch { 257 | // If JSON parsing fails, use the response text 258 | const text = await response.text(); 259 | errorMsg = `AsterDEX API error: ${response.status} - ${text || response.statusText}`; 260 | } 261 | 262 | // Check for insufficient balance errors (common error codes: -2019, -2010) 263 | if (errorCode === -2019 || errorCode === -2010 || errorMsg.toLowerCase().includes("balance") || errorMsg.toLowerCase().includes("insufficient")) { 264 | console.warn(`[LiveExecutor] Insufficient balance: ${errorMsg}`); 265 | throw new Error(`Insufficient balance: ${errorMsg}`); 266 | } 267 | 268 | console.error(`[LiveExecutor] API request failed: ${method} ${endpoint}`, { url, errorMsg }); 269 | throw new Error(errorMsg); 270 | } 271 | 272 | return response.json(); 273 | } catch (error) { 274 | if (error instanceof Error) { 275 | throw error; 276 | } 277 | throw new Error(`Network error: ${String(error)}`); 278 | } 279 | } 280 | 281 | private generateSignature(queryString: string): string { 282 | return crypto.createHmac("sha256", this.apiSecret).update(queryString).digest("hex"); 283 | } 284 | } 285 | 286 | -------------------------------------------------------------------------------- /src/lib/rest/restPoller.ts: -------------------------------------------------------------------------------- 1 | import type { Credentials } from "../types"; 2 | import crypto from "crypto"; 3 | 4 | type AsterPositionResponse = { 5 | positionAmt: string; 6 | entryPrice: string; 7 | markPrice: string; 8 | unRealizedProfit: string; 9 | liquidationPrice: string; 10 | leverage: string; 11 | marginType: string; 12 | isolatedMargin: string; 13 | positionSide: string; 14 | symbol: string; 15 | }; 16 | 17 | type AsterBalanceResponse = { 18 | asset: string; 19 | balance: string; 20 | availableBalance: string; 21 | maxWithdrawAmount: string; 22 | }; 23 | 24 | export class RestPoller { 25 | private readonly baseUrl: string; 26 | private readonly apiKey: string; 27 | private readonly apiSecret: string; 28 | private readonly symbol: string; 29 | private intervalId: NodeJS.Timeout | null = null; 30 | private onPositionUpdate?: (position: AsterPositionResponse) => void; 31 | private onBalanceUpdate?: (balance: AsterBalanceResponse[]) => void; 32 | private onError?: (error: Error) => void; 33 | private lastSuccessLog: number = 0; 34 | 35 | constructor(credentials: Credentials) { 36 | this.baseUrl = credentials.rpcUrl; 37 | this.apiKey = credentials.apiKey; 38 | this.apiSecret = credentials.apiSecret; 39 | // Normalize symbol: remove -PERP suffix and convert to uppercase for REST API 40 | this.symbol = this.normalizeSymbol(credentials.pairSymbol); 41 | } 42 | 43 | private normalizeSymbol(symbol: string): string { 44 | // AsterDEX REST API expects ASTERUSDT (not ASTERUSDT-PERP) 45 | return symbol.toUpperCase().replace(/-PERP$/, ""); 46 | } 47 | 48 | start(intervalMs: number = 2000): void { 49 | this.stop(); 50 | this.intervalId = setInterval(() => { 51 | this.poll(); 52 | }, intervalMs); 53 | // Poll immediately 54 | this.poll(); 55 | } 56 | 57 | stop(): void { 58 | if (this.intervalId) { 59 | clearInterval(this.intervalId); 60 | this.intervalId = null; 61 | } 62 | } 63 | 64 | on(event: "position", handler: (position: AsterPositionResponse) => void): void; 65 | on(event: "balance", handler: (balance: AsterBalanceResponse[]) => void): void; 66 | on(event: "error", handler: (error: Error) => void): void; 67 | on(event: string, handler: unknown): void { 68 | if (event === "position") { 69 | this.onPositionUpdate = handler as (position: AsterPositionResponse) => void; 70 | } else if (event === "balance") { 71 | this.onBalanceUpdate = handler as (balance: AsterBalanceResponse[]) => void; 72 | } else if (event === "error") { 73 | this.onError = handler as (error: Error) => void; 74 | } 75 | } 76 | 77 | private async poll(): Promise { 78 | try { 79 | // Fetch position and balance in parallel 80 | await Promise.all([ 81 | this.fetchPosition().catch((err) => { 82 | console.error(`[RestPoller] Position fetch error:`, err); 83 | throw err; 84 | }), 85 | this.fetchBalance().catch((err) => { 86 | console.error(`[RestPoller] Balance fetch error:`, err.message); 87 | throw err; 88 | }), 89 | ]); 90 | } catch (error) { 91 | const err = error instanceof Error ? error : new Error(String(error)); 92 | console.error(`[RestPoller] Poll error:`, err); 93 | this.onError?.(err); 94 | } 95 | } 96 | 97 | private async fetchPosition(): Promise { 98 | // Use /fapi/v2/account endpoint like the reference code (more reliable) 99 | // Reference code shows: asterPrivate.fapiPrivateV2GetAccount() 100 | try { 101 | // Account endpoint returns all positions in account.positions array 102 | const account = await this.signedRequest<{ positions: AsterPositionResponse[] }>("GET", "/fapi/v2/account", new URLSearchParams()); 103 | const pos = account.positions?.find((p) => p.symbol === this.symbol); 104 | // Always send position update, even if flat (0), so reconciliation can work 105 | if (pos) { 106 | this.onPositionUpdate?.(pos); 107 | } else { 108 | // If position not found, send flat position update 109 | this.onPositionUpdate?.({ 110 | positionAmt: "0", 111 | entryPrice: "0", 112 | markPrice: "0", 113 | unRealizedProfit: "0", 114 | liquidationPrice: "0", 115 | leverage: "1", 116 | marginType: "cross", 117 | isolatedMargin: "0", 118 | positionSide: "BOTH", 119 | symbol: this.symbol, 120 | }); 121 | } 122 | // Log successful poll (only once per minute to avoid spam) 123 | const now = Date.now(); 124 | if (!this.lastSuccessLog || now - this.lastSuccessLog > 60000) { 125 | console.log(`[RestPoller] Position poll successful (symbol: ${this.symbol}, position: ${pos?.positionAmt || "0"})`); 126 | this.lastSuccessLog = now; 127 | } 128 | } catch (error) { 129 | // Fallback to positionRisk endpoint if account endpoint fails 130 | const err = error instanceof Error ? error : new Error(String(error)); 131 | console.warn(`[RestPoller] Account endpoint failed, trying fallback: ${err.message}`); 132 | try { 133 | const params = new URLSearchParams({ 134 | symbol: this.symbol, 135 | }); 136 | const position = await this.signedRequest("GET", "/fapi/v2/positionRisk", params); 137 | const pos = position.find((p) => p.symbol === this.symbol); 138 | // Always send position update, even if flat (0), so reconciliation can work 139 | if (pos) { 140 | this.onPositionUpdate?.(pos); 141 | } else { 142 | // If position not found, send flat position update 143 | this.onPositionUpdate?.({ 144 | positionAmt: "0", 145 | entryPrice: "0", 146 | markPrice: "0", 147 | unRealizedProfit: "0", 148 | liquidationPrice: "0", 149 | leverage: "1", 150 | marginType: "cross", 151 | isolatedMargin: "0", 152 | positionSide: "BOTH", 153 | symbol: this.symbol, 154 | }); 155 | } 156 | } catch (fallbackError) { 157 | const fallbackErr = fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError)); 158 | console.error(`[RestPoller] Both endpoints failed: ${fallbackErr.message}`); 159 | this.onError?.(fallbackErr); 160 | } 161 | } 162 | } 163 | 164 | private async fetchBalance(): Promise { 165 | try { 166 | // According to AsterDEX API docs: GET /fapi/v2/balance (Futures Account Balance V2) 167 | // This endpoint requires USER_DATA permissions 168 | const params = new URLSearchParams(); 169 | const balance = await this.signedRequest("GET", "/fapi/v2/balance", params); 170 | 171 | if (!Array.isArray(balance)) { 172 | console.error(`[RestPoller] Balance response is not an array:`, typeof balance); 173 | throw new Error("Balance response is not an array"); 174 | } 175 | 176 | if (!Array.isArray(balance) || balance.length === 0) { 177 | console.warn(`[RestPoller] Balance response is empty or invalid`); 178 | return; 179 | } 180 | 181 | if (this.onBalanceUpdate) { 182 | this.onBalanceUpdate(balance); 183 | } else { 184 | console.error(`[RestPoller] onBalanceUpdate handler is not set!`); 185 | } 186 | } catch (error) { 187 | // Fallback: try to get balance from /fapi/v2/account endpoint 188 | const err = error instanceof Error ? error : new Error(String(error)); 189 | console.warn(`[RestPoller] Balance endpoint (/fapi/v2/balance) failed: ${err.message}`); 190 | console.warn(`[RestPoller] Error details:`, err); 191 | 192 | try { 193 | const accountParams = new URLSearchParams(); 194 | console.log(`[RestPoller] Trying fallback: fetching from /fapi/v2/account...`); 195 | const account = await this.signedRequest<{ 196 | assets?: Array<{ 197 | asset: string; 198 | availableBalance: string; 199 | balance: string; 200 | walletBalance?: string; 201 | }>; 202 | totalWalletBalance?: string; 203 | }>("GET", "/fapi/v2/account", accountParams); 204 | 205 | console.log(`[RestPoller] Account response received`); 206 | console.log(`[RestPoller] DEBUG - Account response keys:`, Object.keys(account)); 207 | console.log(`[RestPoller] DEBUG - Account response structure (first 500 chars):`, JSON.stringify(account).substring(0, 500)); 208 | 209 | if (account.assets && Array.isArray(account.assets)) { 210 | console.log(`[RestPoller] Found ${account.assets.length} assets in account response`); 211 | 212 | // DEBUG: Log first asset structure 213 | if (account.assets.length > 0) { 214 | console.log(`[RestPoller] DEBUG - First account asset structure:`, JSON.stringify(account.assets[0], null, 2)); 215 | } 216 | 217 | // Convert account assets format to balance format 218 | type AccountAsset = { asset: string; availableBalance?: string; balance?: string; walletBalance?: string }; 219 | const balances: AsterBalanceResponse[] = account.assets.map((asset: AccountAsset) => ({ 220 | asset: asset.asset, 221 | balance: asset.balance || asset.walletBalance || "0", 222 | availableBalance: asset.availableBalance || asset.balance || asset.walletBalance || "0", 223 | maxWithdrawAmount: asset.availableBalance || asset.balance || asset.walletBalance || "0", 224 | })); 225 | console.log(`[RestPoller] Converted ${balances.length} balances from account endpoint`); 226 | 227 | // DEBUG: Log USDT from converted balances 228 | const usdtConverted = balances.find((b) => b.asset?.toUpperCase() === "USDT"); 229 | if (usdtConverted) { 230 | console.log(`[RestPoller] DEBUG - Converted USDT balance:`, JSON.stringify(usdtConverted, null, 2)); 231 | } 232 | 233 | this.onBalanceUpdate?.(balances); 234 | } else { 235 | console.warn(`[RestPoller] Account response does not contain assets array. Response keys:`, Object.keys(account)); 236 | // Try to extract balance from account response if it's in a different format 237 | if (account.totalWalletBalance) { 238 | console.log(`[RestPoller] Found totalWalletBalance: ${account.totalWalletBalance}`); 239 | } 240 | } 241 | } catch (fallbackError) { 242 | const fallbackErr = fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError)); 243 | console.error(`[RestPoller] Both balance endpoints failed:`); 244 | console.error(`[RestPoller] Primary error: ${err.message}`); 245 | console.error(`[RestPoller] Primary error stack:`, err.stack); 246 | console.error(`[RestPoller] Fallback error: ${fallbackErr.message}`); 247 | console.error(`[RestPoller] Fallback error stack:`, fallbackErr.stack); 248 | this.onError?.(fallbackErr); 249 | } 250 | } 251 | } 252 | 253 | private async signedRequest(method: "GET" | "POST", endpoint: string, params: URLSearchParams): Promise { 254 | const timestamp = Date.now(); 255 | params.append("timestamp", timestamp.toString()); 256 | 257 | const queryString = params.toString(); 258 | const signature = crypto.createHmac("sha256", this.apiSecret).update(queryString).digest("hex"); 259 | 260 | const url = `${this.baseUrl}${endpoint}?${queryString}&signature=${signature}`; 261 | 262 | try { 263 | const response = await fetch(url, { 264 | method, 265 | headers: { 266 | "X-MBX-APIKEY": this.apiKey, 267 | "Content-Type": "application/json", 268 | }, 269 | }); 270 | 271 | if (!response.ok) { 272 | let errorMsg = `HTTP ${response.status}: ${response.statusText}`; 273 | try { 274 | const error = await response.json(); 275 | errorMsg = `AsterDEX REST error: ${error.code || response.status} - ${error.msg || response.statusText}`; 276 | } catch { 277 | // If JSON parsing fails, use the response text 278 | const text = await response.text(); 279 | errorMsg = `AsterDEX REST error: ${response.status} - ${text || response.statusText}`; 280 | } 281 | throw new Error(errorMsg); 282 | } 283 | 284 | return response.json(); 285 | } catch (error) { 286 | if (error instanceof Error) { 287 | throw error; 288 | } 289 | throw new Error(`Network error: ${String(error)}`); 290 | } 291 | } 292 | } 293 | 294 | -------------------------------------------------------------------------------- /src/lib/peachHybridEngine.ts: -------------------------------------------------------------------------------- 1 | import { EMA } from "./indicators/ema"; 2 | import { RSI } from "./indicators/rsi"; 3 | import { ADX } from "./indicators/adx"; 4 | import type { PeachConfig, StrategySignal, SyntheticBar } from "./types"; 5 | 6 | export class PeachHybridEngine { 7 | private readonly config: PeachConfig; 8 | 9 | // V1 System (Trend/Bias) 10 | private readonly v1EmaFast: EMA; 11 | private readonly v1EmaMid: EMA; 12 | private readonly v1EmaSlow: EMA; 13 | private readonly v1EmaMicroFast: EMA; 14 | private readonly v1EmaMicroSlow: EMA; 15 | private readonly v1Rsi: RSI; 16 | private v1LastLongLook = false; 17 | private v1LastShortLook = false; 18 | private v1LastLongPrice = 0; 19 | private v1LastShortPrice = 0; 20 | private v1BarsSinceLastSignal = 0; 21 | 22 | // V2 System (Momentum Surge) 23 | private readonly v2EmaFast: EMA; 24 | private readonly v2EmaMid: EMA; 25 | private readonly v2EmaSlow: EMA; 26 | private readonly v2Rsi: RSI; 27 | private v2RsiHistory: number[] = []; 28 | private volumeHistory: number[] = []; 29 | private readonly adx: ADX; 30 | private position: { side: "long" | "short" | "flat" } | null = null; 31 | 32 | constructor(config: PeachConfig) { 33 | this.config = config; 34 | 35 | // Initialize V1 indicators 36 | this.v1EmaFast = new EMA(config.v1.emaFastLen); 37 | this.v1EmaMid = new EMA(config.v1.emaMidLen); 38 | this.v1EmaSlow = new EMA(config.v1.emaSlowLen); 39 | this.v1EmaMicroFast = new EMA(config.v1.emaMicroFastLen); 40 | this.v1EmaMicroSlow = new EMA(config.v1.emaMicroSlowLen); 41 | this.v1Rsi = new RSI(config.v1.rsiLength); 42 | 43 | // Initialize V2 indicators 44 | this.v2EmaFast = new EMA(config.v2.emaFastLen); 45 | this.v2EmaMid = new EMA(config.v2.emaMidLen); 46 | this.v2EmaSlow = new EMA(config.v2.emaSlowLen); 47 | this.v2Rsi = new RSI(14); // RSI length is 14, momentum threshold is separate 48 | this.adx = new ADX(14); // ADX for market regime detection 49 | } 50 | 51 | update(bar: SyntheticBar): StrategySignal | null { 52 | const closePrice = bar.close; 53 | const volume = bar.volume; 54 | 55 | // Update V1 indicators 56 | const v1EmaFast = this.v1EmaFast.update(closePrice); 57 | const v1EmaMid = this.v1EmaMid.update(closePrice); 58 | const v1EmaSlow = this.v1EmaSlow.update(closePrice); 59 | const v1EmaMicroFast = this.v1EmaMicroFast.update(closePrice); 60 | const v1EmaMicroSlow = this.v1EmaMicroSlow.update(closePrice); 61 | const v1Rsi = this.v1Rsi.update(closePrice); 62 | 63 | // Update V2 indicators 64 | const v2EmaFast = this.v2EmaFast.update(closePrice); 65 | const v2EmaMid = this.v2EmaMid.update(closePrice); 66 | const v2EmaSlow = this.v2EmaSlow.update(closePrice); 67 | const v2Rsi = this.v2Rsi.update(closePrice); 68 | 69 | // Update ADX for market regime detection 70 | this.adx.update(bar.high, bar.low, closePrice); 71 | 72 | // Track RSI history for momentum calculation 73 | if (v2Rsi !== null) { 74 | this.v2RsiHistory.push(v2Rsi); 75 | if (this.v2RsiHistory.length > 2) { 76 | this.v2RsiHistory.shift(); 77 | } 78 | } 79 | 80 | // Track volume history (keep last N bars for average calculation) 81 | this.volumeHistory.push(volume); 82 | const maxVolumeHistory = Math.max(this.config.v2.volumeLookback, 10); // Keep at least 10 for better average 83 | if (this.volumeHistory.length > maxVolumeHistory) { 84 | this.volumeHistory.shift(); 85 | } 86 | 87 | // Increment bars since last signal 88 | this.v1BarsSinceLastSignal++; 89 | 90 | // Check V1 System (Trend/Bias) 91 | const v1Signal = this.checkV1System(closePrice, v1EmaFast, v1EmaMid, v1EmaSlow, v1EmaMicroFast, v1EmaMicroSlow, v1Rsi); 92 | if (v1Signal) { 93 | return v1Signal; 94 | } 95 | 96 | // Check V2 System (Momentum Surge) 97 | const v2Signal = this.checkV2System(closePrice, v2EmaFast, v2EmaMid, v2EmaSlow, v2Rsi, volume, bar.open, bar.close); 98 | if (v2Signal) { 99 | return v2Signal; 100 | } 101 | 102 | return null; 103 | } 104 | 105 | private checkV1System( 106 | price: number, 107 | emaFast: number, 108 | emaMid: number, 109 | emaSlow: number, 110 | emaMicroFast: number, 111 | emaMicroSlow: number, 112 | rsi: number | null 113 | ): StrategySignal | null { 114 | if (rsi === null) return null; 115 | 116 | // V1: Bias flip + RSI threshold + trend confirmation 117 | const bullStack = emaFast > emaMid && emaMid > emaSlow; 118 | const bearStack = emaFast < emaMid && emaMid < emaSlow; 119 | const microBullStack = emaMicroFast > emaMicroSlow; 120 | const microBearStack = emaMicroFast < emaMicroSlow; 121 | 122 | const longLook = bullStack && microBullStack && rsi > this.config.v1.rsiMinLong; 123 | const shortLook = bearStack && microBearStack && rsi < this.config.v1.rsiMaxShort; 124 | 125 | // Check min bars between signals 126 | if (this.v1BarsSinceLastSignal < this.config.v1.minBarsBetween) { 127 | return null; 128 | } 129 | 130 | // Check min move percent 131 | let priceMoveMet = true; 132 | if (this.v1LastLongPrice > 0) { 133 | const movePercent = Math.abs((price - this.v1LastLongPrice) / this.v1LastLongPrice) * 100; 134 | priceMoveMet = movePercent >= this.config.v1.minMovePercent; 135 | } 136 | if (this.v1LastShortPrice > 0) { 137 | const movePercent = Math.abs((price - this.v1LastShortPrice) / this.v1LastShortPrice) * 100; 138 | priceMoveMet = priceMoveMet && movePercent >= this.config.v1.minMovePercent; 139 | } 140 | 141 | if (!priceMoveMet) { 142 | return null; 143 | } 144 | 145 | const longTrig = longLook && !this.v1LastLongLook; 146 | const shortTrig = shortLook && !this.v1LastShortLook; 147 | 148 | this.v1LastLongLook = longLook; 149 | this.v1LastShortLook = shortLook; 150 | 151 | if (longTrig) { 152 | this.v1LastLongPrice = price; 153 | this.v1BarsSinceLastSignal = 0; 154 | return { 155 | type: "long", 156 | reason: "v1-long", 157 | system: "v1", 158 | indicators: { 159 | emaFast, 160 | emaMid, 161 | emaSlow, 162 | rsi, 163 | }, 164 | trend: { 165 | bullStack, 166 | bearStack, 167 | longLook, 168 | shortLook, 169 | longTrig, 170 | shortTrig, 171 | }, 172 | }; 173 | } 174 | 175 | if (shortTrig) { 176 | this.v1LastShortPrice = price; 177 | this.v1BarsSinceLastSignal = 0; 178 | return { 179 | type: "short", 180 | reason: "v1-short", 181 | system: "v1", 182 | indicators: { 183 | emaFast, 184 | emaMid, 185 | emaSlow, 186 | rsi, 187 | }, 188 | trend: { 189 | bullStack, 190 | bearStack, 191 | longLook, 192 | shortLook, 193 | longTrig, 194 | shortTrig, 195 | }, 196 | }; 197 | } 198 | 199 | return null; 200 | } 201 | 202 | private checkV2System( 203 | price: number, 204 | emaFast: number, 205 | emaMid: number, 206 | emaSlow: number, 207 | rsi: number | null, 208 | volume: number, 209 | barOpen: number, 210 | barClose: number 211 | ): StrategySignal | null { 212 | if (rsi === null || this.v2RsiHistory.length < 2) return null; 213 | 214 | // V2: RSI surge + volume spike + volume color + EMA direction 215 | const rsiMomentum = this.v2RsiHistory[this.v2RsiHistory.length - 1] - this.v2RsiHistory[0]; 216 | const rsiSurge = Math.abs(rsiMomentum) >= this.config.v2.rsiMomentumThreshold; 217 | 218 | // Calculate average volume 219 | const avgVolume = this.volumeHistory.reduce((sum, v) => sum + v, 0) / this.volumeHistory.length; 220 | const volumeSpike = volume >= avgVolume * this.config.v2.volumeMultiplier; 221 | 222 | // Volume color (green = bullish, red = bearish) - using bar close vs open 223 | // Green volume = close > open (bullish), Red volume = close < open (bearish) 224 | const volumeColor = barClose > barOpen; 225 | 226 | // EMA direction 227 | const emaBullish = emaFast > emaMid && emaMid > emaSlow; 228 | const emaBearish = emaFast < emaMid && emaMid < emaSlow; 229 | 230 | // Long signal: RSI surge up + volume spike + green volume + bullish EMA 231 | if (rsiSurge && rsiMomentum > 0 && volumeSpike && volumeColor && emaBullish) { 232 | return { 233 | type: "long", 234 | reason: "v2-long", 235 | system: "v2", 236 | indicators: { 237 | emaFast, 238 | emaMid, 239 | emaSlow, 240 | rsi, 241 | }, 242 | trend: { 243 | bullStack: emaBullish, 244 | bearStack: emaBearish, 245 | longLook: true, 246 | shortLook: false, 247 | longTrig: true, 248 | shortTrig: false, 249 | }, 250 | }; 251 | } 252 | 253 | // Short signal: RSI surge down + volume spike + red volume + bearish EMA 254 | if (rsiSurge && rsiMomentum < 0 && volumeSpike && !volumeColor && emaBearish) { 255 | return { 256 | type: "short", 257 | reason: "v2-short", 258 | system: "v2", 259 | indicators: { 260 | emaFast, 261 | emaMid, 262 | emaSlow, 263 | rsi, 264 | }, 265 | trend: { 266 | bullStack: emaBullish, 267 | bearStack: emaBearish, 268 | longLook: false, 269 | shortLook: true, 270 | longTrig: false, 271 | shortTrig: true, 272 | }, 273 | }; 274 | } 275 | 276 | return null; 277 | } 278 | 279 | // Update position state for exit logic 280 | setPosition(side: "long" | "short" | "flat"): void { 281 | this.position = { side }; 282 | } 283 | 284 | // Check exit conditions 285 | checkExitConditions(bar: SyntheticBar): { 286 | shouldExit: boolean; 287 | reason: string; 288 | details?: Record; 289 | } { 290 | if (!this.position || this.position.side === "flat") { 291 | return { shouldExit: false, reason: "" }; 292 | } 293 | 294 | const volume = bar.volume; 295 | const avgVolume = this.volumeHistory.length > 0 ? this.volumeHistory.reduce((sum, v) => sum + v, 0) / this.volumeHistory.length : volume; 296 | 297 | // Check RSI flattening with volume drop 298 | if (this.v2RsiHistory.length >= 3) { 299 | // Use last 3 RSI values for more stable momentum calculation 300 | const recentRSI = this.v2RsiHistory.slice(-3); 301 | const rsiMomentum = Math.abs(recentRSI[recentRSI.length - 1] - recentRSI[0]); 302 | const volumeMultiplier = avgVolume > 0 ? volume / avgVolume : 1; 303 | 304 | // More sophisticated exit conditions 305 | const rsiFlattening = rsiMomentum < 2.0; // Increased threshold for stability 306 | const volumeDrop = volumeMultiplier < this.config.v2.exitVolumeMultiplier; 307 | 308 | // Additional condition: check if RSI is moving against position (adverse movement) 309 | let adverseRSI = false; 310 | if (this.position.side === "long" && recentRSI[recentRSI.length - 1] < recentRSI[0]) { 311 | adverseRSI = true; 312 | } else if (this.position.side === "short" && recentRSI[recentRSI.length - 1] > recentRSI[0]) { 313 | adverseRSI = true; 314 | } 315 | 316 | if ((rsiFlattening && volumeDrop) || adverseRSI) { 317 | return { 318 | shouldExit: true, 319 | reason: adverseRSI ? "rsi-reversal" : "rsi-flattening-volume-drop", 320 | details: { 321 | rsiMomentum, 322 | volume, 323 | avgVolume, 324 | volumeMultiplier, 325 | recentRSI, 326 | adverseRSI, 327 | position: this.position.side, 328 | }, 329 | }; 330 | } 331 | } 332 | 333 | return { shouldExit: false, reason: "" }; 334 | } 335 | 336 | get settings(): PeachConfig { 337 | return this.config; 338 | } 339 | 340 | getIndicatorValues(): { 341 | v1: { emaFast: number | null; emaMid: number | null; emaSlow: number | null; rsi: number | null }; 342 | v2: { emaFast: number | null; emaMid: number | null; emaSlow: number | null; rsi: number | null }; 343 | adx: number | null; 344 | } { 345 | return { 346 | v1: { 347 | emaFast: this.v1EmaFast.value, 348 | emaMid: this.v1EmaMid.value, 349 | emaSlow: this.v1EmaSlow.value, 350 | rsi: this.v1Rsi.value, 351 | }, 352 | v2: { 353 | emaFast: this.v2EmaFast.value, 354 | emaMid: this.v2EmaMid.value, 355 | emaSlow: this.v2EmaSlow.value, 356 | rsi: this.v2Rsi.value, 357 | }, 358 | adx: this.adx.value, 359 | }; 360 | } 361 | 362 | // Check if market regime allows trading 363 | shouldAllowTrading(adxThreshold: number = 25): boolean { 364 | // If ADX is not ready yet, allow trading (give it time to warm up) 365 | // Once ADX is ready, check if market is trending 366 | return !this.adx.isReady || this.adx.isTrending(adxThreshold); 367 | } 368 | } 369 | 370 | -------------------------------------------------------------------------------- /test-step-by-step.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Step-by-Step Bot Testing 3 | * Tests each component individually with detailed output 4 | */ 5 | 6 | import { EMA } from "./src/lib/indicators/ema"; 7 | import { RSI } from "./src/lib/indicators/rsi"; 8 | import { VirtualBarBuilder } from "./src/lib/virtualBarBuilder"; 9 | import { WatermellonEngine } from "./src/lib/watermellonEngine"; 10 | import { PeachHybridEngine } from "./src/lib/peachHybridEngine"; 11 | import { DryRunExecutor } from "./src/lib/execution/dryRunExecutor"; 12 | import { PositionStateManager } from "./src/lib/state/positionState"; 13 | import { StatePersistence } from "./src/lib/state/statePersistence"; 14 | import { OrderTracker } from "./src/lib/execution/orderTracker"; 15 | import type { Tick, SyntheticBar, WatermellonConfig, PeachConfig } from "./src/lib/types"; 16 | 17 | console.log("=".repeat(80)); 18 | console.log("STEP-BY-STEP BOT TESTING"); 19 | console.log("=".repeat(80)); 20 | 21 | // ============================================================================ 22 | // STEP 1: Configuration Testing 23 | // ============================================================================ 24 | console.log("\n" + "=".repeat(80)); 25 | console.log("STEP 1: Configuration Testing"); 26 | console.log("=".repeat(80)); 27 | 28 | console.log("\n[1.1] Testing Watermellon Configuration Structure..."); 29 | const watermellonConfig: WatermellonConfig = { 30 | timeframeMs: 30000, 31 | emaFastLen: 8, 32 | emaMidLen: 21, 33 | emaSlowLen: 48, 34 | rsiLength: 14, 35 | rsiMinLong: 42, 36 | rsiMaxShort: 58, 37 | }; 38 | console.log(" Watermellon config:", JSON.stringify(watermellonConfig, null, 2)); 39 | 40 | console.log("\n[1.2] Testing Peach Hybrid Configuration Structure..."); 41 | const peachConfig: PeachConfig = { 42 | timeframeMs: 30000, 43 | v1: { 44 | emaFastLen: 8, 45 | emaMidLen: 21, 46 | emaSlowLen: 48, 47 | emaMicroFastLen: 5, 48 | emaMicroSlowLen: 13, 49 | rsiLength: 14, 50 | rsiMinLong: 42.0, 51 | rsiMaxShort: 58.0, 52 | minBarsBetween: 1, 53 | minMovePercent: 0.10, 54 | }, 55 | v2: { 56 | emaFastLen: 3, 57 | emaMidLen: 8, 58 | emaSlowLen: 13, 59 | rsiMomentumThreshold: 3.0, 60 | volumeLookback: 4, 61 | volumeMultiplier: 1.5, 62 | exitVolumeMultiplier: 1.2, 63 | }, 64 | }; 65 | console.log(" Peach config structure validated"); 66 | 67 | // ============================================================================ 68 | // STEP 2: Indicator Testing - EMA 69 | // ============================================================================ 70 | console.log("\n" + "=".repeat(80)); 71 | console.log("STEP 2: EMA Indicator Testing"); 72 | console.log("=".repeat(80)); 73 | 74 | console.log("\n[2.1] Testing EMA with ascending values..."); 75 | const ema8 = new EMA(8); 76 | const ascendingPrices = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109]; 77 | console.log("Input prices:", ascendingPrices); 78 | ascendingPrices.forEach((price) => { 79 | const emaValue = ema8.update(price); 80 | console.log(` Price ${price.toFixed(2)} → EMA(8): ${emaValue.toFixed(4)}`); 81 | }); 82 | console.log("EMA correctly tracks upward trend"); 83 | 84 | console.log("\n[2.2] Testing EMA with descending values..."); 85 | const ema21 = new EMA(21); 86 | const descendingPrices = [110, 109, 108, 107, 106, 105, 104, 103, 102, 101]; 87 | console.log("Input prices:", descendingPrices); 88 | descendingPrices.forEach((price) => { 89 | const emaValue = ema21.update(price); 90 | console.log(` Price ${price.toFixed(2)} → EMA(21): ${emaValue.toFixed(4)}`); 91 | }); 92 | console.log(" EMA correctly tracks downward trend"); 93 | 94 | console.log("\n[2.3] Testing EMA initialization..."); 95 | try { 96 | const ema = new EMA(5); 97 | const firstValue = ema.update(100); 98 | console.log(` First value (should equal input): ${firstValue} (expected: 100)`); 99 | console.log(firstValue === 100 ? "EMA initializes correctly" : " EMA initialization failed"); 100 | } catch (error) { 101 | console.error(" EMA initialization error:", error); 102 | } 103 | 104 | console.log("\n[2.4] Testing EMA error handling..."); 105 | try { 106 | new EMA(0); 107 | console.log(" Should have thrown error for length <= 0"); 108 | } catch { 109 | console.log("EMA correctly rejects invalid length"); 110 | } 111 | 112 | // ============================================================================ 113 | // STEP 3: Indicator Testing - RSI 114 | // ============================================================================ 115 | console.log("\n" + "=".repeat(80)); 116 | console.log("STEP 3: RSI Indicator Testing"); 117 | console.log("=".repeat(80)); 118 | 119 | console.log("\n[3.1] Testing RSI with bullish price action..."); 120 | const rsi14 = new RSI(14); 121 | const bullishPrices = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115]; 122 | console.log("Input prices (bullish):", bullishPrices.slice(0, 10), "..."); 123 | bullishPrices.forEach((price, index) => { 124 | const rsiValue = rsi14.update(price); 125 | if (index > 0) { 126 | const trend = rsiValue && rsiValue > 50 ? " Bullish" : rsiValue && rsiValue < 50 ? "Bearish" : "➡️ Neutral"; 127 | console.log(` Price ${price.toFixed(2)} → RSI(14): ${rsiValue?.toFixed(2) ?? "null"} ${trend}`); 128 | } 129 | }); 130 | console.log("RSI correctly identifies bullish trend"); 131 | 132 | console.log("\n[3.2] Testing RSI with bearish price action..."); 133 | const rsi14Bear = new RSI(14); 134 | const bearishPrices = [115, 114, 113, 112, 111, 110, 109, 108, 107, 106, 105, 104, 103, 102, 101, 100]; 135 | console.log("Input prices (bearish):", bearishPrices.slice(0, 10), "..."); 136 | bearishPrices.forEach((price, index) => { 137 | const rsiValue = rsi14Bear.update(price); 138 | if (index > 0) { 139 | const trend = rsiValue && rsiValue > 50 ? " Bullish" : rsiValue && rsiValue < 50 ? " Bearish" : "➡️ Neutral"; 140 | console.log(` Price ${price.toFixed(2)} → RSI(14): ${rsiValue?.toFixed(2) ?? "null"} ${trend}`); 141 | } 142 | }); 143 | console.log(" RSI correctly identifies bearish trend"); 144 | 145 | console.log("\n[3.3] Testing RSI edge case (all gains, no losses)..."); 146 | const rsiEdge = new RSI(14); 147 | for (let i = 0; i < 20; i++) { 148 | const rsiValue = rsiEdge.update(100 + i); 149 | if (i > 0 && rsiValue === 100) { 150 | console.log(` RSI correctly handles edge case (all gains) → RSI: ${rsiValue}`); 151 | break; 152 | } 153 | } 154 | 155 | // ============================================================================ 156 | // STEP 4: Virtual Bar Builder Testing 157 | // ============================================================================ 158 | console.log("\n" + "=".repeat(80)); 159 | console.log("STEP 4: Virtual Bar Builder Testing"); 160 | console.log("=".repeat(80)); 161 | 162 | console.log("\n[4.1] Testing bar creation from ticks..."); 163 | const builder = new VirtualBarBuilder(30000); // 30 second bars 164 | const baseTime = Date.now(); 165 | const ticks: Tick[] = [ 166 | { timestamp: baseTime, price: 100.0, size: 1.0 }, 167 | { timestamp: baseTime + 5000, price: 100.5, size: 2.0 }, 168 | { timestamp: baseTime + 10000, price: 101.0, size: 1.5 }, 169 | { timestamp: baseTime + 15000, price: 100.8, size: 2.5 }, 170 | { timestamp: baseTime + 20000, price: 101.2, size: 1.0 }, 171 | { timestamp: baseTime + 25000, price: 101.5, size: 2.0 }, 172 | { timestamp: baseTime + 30000, price: 102.0, size: 1.5 }, // Should close bar 173 | ]; 174 | 175 | let barCount = 0; 176 | ticks.forEach((tick) => { 177 | const result = builder.pushTick(tick); 178 | if (result.closedBar) { 179 | barCount++; 180 | const bar = result.closedBar; 181 | console.log(`\n Bar ${barCount} closed:`); 182 | console.log(` Open: ${bar.open.toFixed(4)}`); 183 | console.log(` High: ${bar.high.toFixed(4)}`); 184 | console.log(` Low: ${bar.low.toFixed(4)}`); 185 | console.log(` Close: ${bar.close.toFixed(4)}`); 186 | console.log(` Volume: ${bar.volume.toFixed(2)}`); 187 | console.log(` Duration: ${(bar.endTime - bar.startTime) / 1000}s`); 188 | } 189 | }); 190 | console.log(`\n Created ${barCount} complete bar(s)`); 191 | 192 | console.log("\n[4.2] Testing bar with multiple ticks..."); 193 | const builder2 = new VirtualBarBuilder(10000); // 10 second bars 194 | const manyTicks: Tick[] = []; 195 | for (let i = 0; i < 20; i++) { 196 | manyTicks.push({ 197 | timestamp: baseTime + i * 1000, 198 | price: 100 + Math.random() * 2, 199 | size: Math.random() * 2, 200 | }); 201 | } 202 | 203 | let barsCreated = 0; 204 | manyTicks.forEach((tick) => { 205 | const result = builder2.pushTick(tick); 206 | if (result.closedBar) barsCreated++; 207 | }); 208 | console.log(` Processed ${manyTicks.length} ticks, created ${barsCreated} bars`); 209 | console.log("Bar builder handles multiple ticks correctly"); 210 | 211 | // ============================================================================ 212 | // STEP 5: Watermellon Engine Testing 213 | // ============================================================================ 214 | console.log("\n" + "=".repeat(80)); 215 | console.log("STEP 5: Watermellon Engine Testing"); 216 | console.log("=".repeat(80)); 217 | 218 | console.log("\n[5.1] Testing Watermellon engine with bullish scenario..."); 219 | const watermellonEngine = new WatermellonEngine(watermellonConfig); 220 | 221 | // Simulate price action that should trigger a long signal 222 | const bullishSequence = [ 223 | 100, 100.5, 101, 101.5, 102, 102.5, 103, 103.5, 104, 104.5, 224 | 105, 105.5, 106, 106.5, 107, 107.5, 108, 108.5, 109, 109.5, 225 | ]; 226 | 227 | let longSignals = 0; 228 | bullishSequence.forEach((price) => { 229 | const signal = watermellonEngine.update(price); 230 | const indicators = watermellonEngine.getIndicatorValues(); 231 | 232 | if (signal) { 233 | longSignals++; 234 | console.log(`\n Signal ${longSignals} at price ${price.toFixed(2)}:`); 235 | console.log(` Type: ${signal.type}`); 236 | console.log(` Reason: ${signal.reason}`); 237 | console.log(` EMA Fast: ${indicators.emaFast?.toFixed(4)}`); 238 | console.log(` EMA Mid: ${indicators.emaMid?.toFixed(4)}`); 239 | console.log(` EMA Slow: ${indicators.emaSlow?.toFixed(4)}`); 240 | console.log(` RSI: ${indicators.rsi?.toFixed(2)}`); 241 | console.log(` Bull Stack: ${signal.trend.bullStack}`); 242 | } 243 | }); 244 | console.log(`\n Generated ${longSignals} long signal(s)`); 245 | 246 | console.log("\n[5.2] Testing Watermellon engine with bearish scenario..."); 247 | const watermellonEngine2 = new WatermellonEngine(watermellonConfig); 248 | 249 | // Simulate price action that should trigger a short signal 250 | const bearishSequence = [ 251 | 110, 109.5, 109, 108.5, 108, 107.5, 107, 106.5, 106, 105.5, 252 | 105, 104.5, 104, 103.5, 103, 102.5, 102, 101.5, 101, 100.5, 253 | ]; 254 | 255 | let shortSignals = 0; 256 | bearishSequence.forEach((price) => { 257 | const signal = watermellonEngine2.update(price); 258 | if (signal && signal.type === "short") { 259 | shortSignals++; 260 | console.log(` Short signal ${shortSignals} at price ${price.toFixed(2)}`); 261 | } 262 | }); 263 | console.log(` Generated ${shortSignals} short signal(s)`); 264 | 265 | // ============================================================================ 266 | // STEP 6: Peach Hybrid Engine Testing 267 | // ============================================================================ 268 | console.log("\n" + "=".repeat(80)); 269 | console.log("STEP 6: Peach Hybrid Engine Testing"); 270 | console.log("=".repeat(80)); 271 | 272 | console.log("\n[6.1] Testing Peach Hybrid V1 System (Trend/Bias)..."); 273 | const peachEngine = new PeachHybridEngine(peachConfig); 274 | 275 | // Create bars that should trigger V1 signals 276 | const v1Bars: SyntheticBar[] = []; 277 | const v1BaseTime = Date.now(); 278 | for (let i = 0; i < 30; i++) { 279 | const price = 100 + i * 0.3; // Upward trend 280 | v1Bars.push({ 281 | startTime: v1BaseTime + i * 30000, 282 | endTime: v1BaseTime + (i + 1) * 30000, 283 | open: price - 0.1, 284 | high: price + 0.2, 285 | low: price - 0.2, 286 | close: price, 287 | volume: 10 + Math.random() * 5, 288 | }); 289 | } 290 | 291 | let v1Signals = 0; 292 | v1Bars.forEach((bar, index) => { 293 | const signal = peachEngine.update(bar); 294 | if (signal && signal.system === "v1") { 295 | v1Signals++; 296 | console.log(`\n V1 Signal ${v1Signals} at bar ${index}:`); 297 | console.log(` Type: ${signal.type}`); 298 | console.log(` Reason: ${signal.reason}`); 299 | console.log(` Price: ${bar.close.toFixed(4)}`); 300 | } 301 | }); 302 | console.log(`\nV1 system generated ${v1Signals} signal(s)`); 303 | 304 | console.log("\n[6.2] Testing Peach Hybrid V2 System (Momentum Surge)..."); 305 | const peachEngine2 = new PeachHybridEngine(peachConfig); 306 | 307 | // Create bars with momentum surge (RSI spike + volume spike) 308 | const v2Bars: SyntheticBar[] = []; 309 | const v2BaseTime = Date.now(); 310 | for (let i = 0; i < 20; i++) { 311 | const price = 100 + i * 0.5; 312 | // Create volume spike at bar 10 313 | const volume = i === 10 ? 30 : 10; // Volume spike 314 | v2Bars.push({ 315 | startTime: v2BaseTime + i * 30000, 316 | endTime: v2BaseTime + (i + 1) * 30000, 317 | open: price - 0.1, 318 | high: price + 0.3, 319 | low: price - 0.2, 320 | close: price + 0.2, // Green bar (close > open) 321 | volume, 322 | }); 323 | } 324 | 325 | let v2Signals = 0; 326 | v2Bars.forEach((bar, i) => { 327 | const signal = peachEngine2.update(bar); 328 | if (signal && signal.system === "v2") { 329 | v2Signals++; 330 | console.log(`\n V2 Signal ${v2Signals} at bar ${i}:`); 331 | console.log(` Type: ${signal.type}`); 332 | console.log(` Reason: ${signal.reason}`); 333 | console.log(` Price: ${bar.close.toFixed(4)}`); 334 | console.log(` Volume: ${bar.volume.toFixed(2)}`); 335 | } 336 | }); 337 | console.log(`\nV2 system generated ${v2Signals} signal(s)`); 338 | 339 | console.log("\n[6.3] Testing Peach Hybrid exit conditions..."); 340 | const peachEngine3 = new PeachHybridEngine(peachConfig); 341 | 342 | // Create bars that should trigger exit 343 | const exitBars: SyntheticBar[] = []; 344 | const exitBaseTime = Date.now(); 345 | for (let i = 0; i < 10; i++) { 346 | exitBars.push({ 347 | startTime: exitBaseTime + i * 30000, 348 | endTime: exitBaseTime + (i + 1) * 30000, 349 | open: 100 + i * 0.1, 350 | high: 100 + i * 0.1 + 0.2, 351 | low: 100 + i * 0.1 - 0.1, 352 | close: 100 + i * 0.1 + 0.05, // Small moves 353 | volume: 5, // Low volume (below exit threshold) 354 | }); 355 | } 356 | 357 | // First update to build history 358 | exitBars.forEach((bar) => { 359 | peachEngine3.update(bar); 360 | }); 361 | 362 | // Check exit conditions 363 | const lastBar = exitBars[exitBars.length - 1]; 364 | const exitCheck = peachEngine3.checkExitConditions(lastBar); 365 | console.log(` Exit check result:`, exitCheck); 366 | console.log(exitCheck.shouldExit ? "Exit condition detected" : "ℹ No exit condition"); 367 | 368 | // ============================================================================ 369 | // STEP 7: State Management Testing 370 | // ============================================================================ 371 | console.log("\n" + "=".repeat(80)); 372 | console.log("STEP 7: State Management Testing"); 373 | console.log("=".repeat(80)); 374 | 375 | console.log("\n[7.1] Testing PositionStateManager..."); 376 | const stateManager = new PositionStateManager(); 377 | 378 | console.log(" Initial state:", stateManager.getState()); 379 | 380 | stateManager.updateLocalState({ 381 | side: "long", 382 | size: 1000, 383 | avgEntry: 100.5, 384 | unrealizedPnl: 10.5, 385 | }); 386 | 387 | console.log(" After local update:", stateManager.getState()); 388 | 389 | const reconciled = stateManager.updateFromRest({ 390 | positionAmt: "1000", 391 | entryPrice: "100.5", 392 | unrealizedProfit: "10.5", 393 | }); 394 | 395 | console.log(` Reconciliation result: ${reconciled ? "Success" : "Failed"}`); 396 | console.log(" Final state:", stateManager.getState()); 397 | 398 | console.log("\n[7.2] Testing StatePersistence..."); 399 | const statePersistence = new StatePersistence("./test-data"); 400 | 401 | const testState = { 402 | position: { 403 | side: "long" as const, 404 | size: 1000, 405 | avgEntry: 100.5, 406 | unrealizedPnl: 10.5, 407 | lastUpdate: Date.now(), 408 | }, 409 | lastBarCloseTime: Date.now(), 410 | }; 411 | 412 | statePersistence.save(testState); 413 | console.log(" State saved"); 414 | 415 | const loaded = statePersistence.load(); 416 | console.log(` State loaded: ${loaded ? " Success" : " Failed"}`); 417 | if (loaded) { 418 | console.log(" Loaded state:", loaded); 419 | } 420 | 421 | // Clean up 422 | statePersistence.clear(); 423 | console.log(" test data cleaned up"); 424 | 425 | console.log("\n[7.3] Testing OrderTracker..."); 426 | const orderTracker = new OrderTracker(); 427 | 428 | const testOrder = { 429 | side: "long" as const, 430 | size: 1000, 431 | leverage: 5, 432 | price: 100.5, 433 | signalReason: "test", 434 | timestamp: Date.now(), 435 | }; 436 | 437 | orderTracker.trackOrder(testOrder, "order-1"); 438 | console.log(" Order tracked"); 439 | console.log(` Pending orders: ${orderTracker.hasPendingOrders()}`); 440 | 441 | orderTracker.confirmOrder("order-1"); 442 | console.log(" Order confirmed"); 443 | console.log(` Pending orders: ${orderTracker.hasPendingOrders()}`); 444 | 445 | // ============================================================================ 446 | // STEP 8: Execution Adapter Testing 447 | // ============================================================================ 448 | (async () => { 449 | console.log("\n" + "=".repeat(80)); 450 | console.log("STEP 8: Execution Adapter Testing"); 451 | console.log("=".repeat(80)); 452 | 453 | console.log("\n[8.1] Testing DryRunExecutor..."); 454 | const executor = new DryRunExecutor(); 455 | 456 | const longOrder = { 457 | side: "long" as const, 458 | size: 1000, 459 | leverage: 5, 460 | price: 100.5, 461 | signalReason: "test-long", 462 | timestamp: Date.now(), 463 | }; 464 | 465 | const shortOrder = { 466 | side: "short" as const, 467 | size: 500, 468 | leverage: 3, 469 | price: 101.0, 470 | signalReason: "test-short", 471 | timestamp: Date.now() + 1000, 472 | }; 473 | 474 | await executor.enterLong(longOrder); 475 | console.log(" Long order executed (dry-run)"); 476 | 477 | await executor.enterShort(shortOrder); 478 | console.log(" Short order executed (dry-run)"); 479 | 480 | await executor.closePosition("test-close"); 481 | console.log(" Position closed (dry-run)"); 482 | 483 | const logs = executor.logs; 484 | console.log(`\n Total log entries: ${logs.length}`); 485 | logs.forEach((log, i) => { 486 | console.log(` ${i + 1}. ${log.type}${log.type === "enter" ? ` (${log.side})` : ""}`); 487 | }); 488 | 489 | // ============================================================================ 490 | // SUMMARY 491 | // ============================================================================ 492 | console.log("\n" + "=".repeat(80)); 493 | console.log("TESTING SUMMARY"); 494 | console.log("=".repeat(80)); 495 | console.log("\nAll step-by-step tests completed successfully!"); 496 | console.log("\nComponents tested:"); 497 | console.log(" 1. Configuration structures"); 498 | console.log(" 2. EMA Indicator"); 499 | console.log(" 3. RSI Indicator"); 500 | console.log(" 4. Virtual Bar Builder"); 501 | console.log(" 5. Watermellon Engine"); 502 | console.log(" 6. Peach Hybrid Engine (V1 & V2)"); 503 | console.log(" 7. State Management"); 504 | console.log(" 8. Execution Adapter"); 505 | console.log("\n" + "=".repeat(80)); 506 | })(); 507 | 508 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aster Trading Bot 2 | 3 | [![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) 4 | [![Next.js](https://img.shields.io/badge/Next.js-000000?style=flat&logo=next.js&logoColor=white)](https://nextjs.org/) 5 | [![Node.js](https://img.shields.io/badge/Node.js-20+-339933?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/) 6 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 7 | 8 | > **Aster Trading Bot** that implements the TradingView "Watermellon" strategy (EMA 8/21/48 + RSI14) and Peach Hybrid strategy for **AsterDEX cryptocurrency exchange**. Built with Next.js and TypeScript for high-performance algorithmic trading with real-time market data processing. 9 | 10 | ## Table of Contents 11 | 12 | - [Overview](#overview) 13 | - [Features](#features) 14 | - [Trading Strategies](#trading-strategies) 15 | - [Prerequisites](#prerequisites) 16 | - [Installation](#installation) 17 | - [Configuration](#configuration) 18 | - [Usage](#usage) 19 | - [Project Structure](#project-structure) 20 | - [Risk Management](#risk-management) 21 | - [FAQ](#faq) 22 | - [Contributing](#contributing) 23 | - [Keywords](#keywords) 24 | - [Contact](#contact) 25 | - [Donation](#donation) 26 | 27 | ## Overview 28 | 29 | **Aster Trading Bot** is an advanced **automated cryptocurrency trading bot** designed for the **AsterDEX exchange**. It recreates the popular TradingView "Watermellon" script using a sophisticated combination of **Exponential Moving Averages (EMA)** and **Relative Strength Index (RSI)** indicators. The bot processes real-time market data through WebSocket connections, builds synthetic 30-second bars from raw ticks, and executes trades based on technical analysis signals. 30 | 31 | ### What Makes This Bot Special? 32 | 33 | - **Real-time Market Data Processing**: Direct WebSocket integration with AsterDEX for low-latency trading 34 | - **Multiple Trading Strategies**: Supports both Watermellon and Peach Hybrid strategies 35 | - **Risk Management**: Built-in position sizing, leverage limits, and stop-loss protection 36 | - **Dry-Run Mode**: Test strategies safely before live trading 37 | - **Modern Tech Stack**: Next.js dashboard + TypeScript for reliability and maintainability 38 | - **Synthetic Bar Building**: Converts raw ticks into OHLCV bars for indicator calculations 39 | 40 | ## Features 41 | 42 | ### Core Capabilities 43 | 44 | - **Automated Trading**: Fully automated execution of trading strategies on AsterDEX 45 | - **Dual Strategy Support**: 46 | - **Watermellon Strategy**: EMA 8/21/48 stack with RSI(14) filters 47 | - **Peach Hybrid Strategy**: Advanced trend and momentum-based system 48 | - **Real-time Indicators**: 49 | - Exponential Moving Averages (EMA) with configurable periods 50 | - Relative Strength Index (RSI) for momentum analysis 51 | - ADX (Average Directional Index) for trend strength 52 | - **Risk Management**: 53 | - Maximum position size limits 54 | - Leverage controls (up to 1000x for premium) 55 | - Stop-loss and take-profit orders 56 | - Flip rate limiting to prevent overtrading 57 | - Emergency stop-loss protection 58 | - **Execution Modes**: 59 | - **Dry-run mode**: Simulate trades without real money 60 | - **Live mode**: Execute real trades on AsterDEX 61 | - **State Persistence**: Bot state survives restarts 62 | - **Web Dashboard**: Next.js-based monitoring interface 63 | - **Order Tracking**: Real-time order status monitoring 64 | 65 | ### Technical Features 66 | 67 | - **Synthetic Bar Building**: Converts tick data into configurable timeframe bars 68 | - **Event Deduplication**: Prevents duplicate signal processing 69 | - **Structured Logging**: JSON logs for analysis and debugging 70 | - **Type-Safe Configuration**: Zod schema validation for environment variables 71 | - **WebSocket Reconnection**: Automatic reconnection handling 72 | - **Position State Management**: Tracks open positions and trade history 73 | 74 | ## Trading Strategies 75 | 76 | ### Watermellon Strategy 77 | 78 | The **Watermellon strategy** uses a triple EMA stack combined with RSI filters: 79 | 80 | - **EMA Stack**: Fast (8), Mid (21), and Slow (48) period EMAs 81 | - **Trend Gate**: Fast > Mid > Slow for long positions, inverse for shorts 82 | - **RSI Filter**: RSI(14) > 42 for long signals, < 58 for short signals 83 | - **Signal Logic**: Triggers only on rising edges to avoid duplicate entries 84 | 85 | **Default Parameters:** 86 | - EMA Fast: 8 periods 87 | - EMA Mid: 21 periods 88 | - EMA Slow: 48 periods 89 | - RSI Length: 14 periods 90 | - RSI Min Long: 42 91 | - RSI Max Short: 58 92 | - Timeframe: 30 seconds (synthetic bars) 93 | 94 | ### Peach Hybrid Strategy 95 | 96 | The **Peach Hybrid strategy** combines two systems: 97 | 98 | **V1 System (Trend/Bias):** 99 | - Multi-EMA configuration for trend detection 100 | - Micro EMAs for fine-tuned entry timing 101 | - RSI-based bias confirmation 102 | - Minimum move percentage filters 103 | 104 | **V2 System (Momentum Surge):** 105 | - Volume-based momentum detection 106 | - Volume multiplier thresholds 107 | - Exit volume analysis 108 | - Momentum surge identification 109 | 110 | ## Prerequisites 111 | 112 | Before you begin, ensure you have the following installed: 113 | 114 | - **Node.js** 20 or higher ([Download](https://nodejs.org/)) 115 | - **npm** (comes bundled with Node.js) or **pnpm**/**bun** (optional) 116 | - **AsterDEX Account**: Create an account at [asterdex.com](https://asterdex.com) 117 | - **API Credentials**: Generate API key and secret from AsterDEX dashboard 118 | - **Dedicated Wallet**: A wallet private key dedicated to this bot (recommended for security) 119 | 120 | ## Installation 121 | 122 | ### Step 1: Clone the Repository 123 | 124 | ```bash 125 | git clone 126 | cd aster-bot 127 | ``` 128 | 129 | ### Step 2: Install Dependencies 130 | 131 | ```bash 132 | npm install 133 | ``` 134 | 135 | ### Step 3: Configure Environment Variables 136 | 137 | Copy the example environment file and configure it: 138 | 139 | ```bash 140 | cp env.example .env.local 141 | ``` 142 | 143 | Edit `.env.local` with your AsterDEX credentials and trading parameters. See [Configuration](#configuration) section for details. 144 | 145 | ### Step 4: Run the Bot 146 | 147 | **Development Mode (Dashboard):** 148 | ```bash 149 | npm run dev 150 | ``` 151 | Visit `http://localhost:3000` to access the trading dashboard and monitor bot activity. 152 | 153 | **Headless Bot Mode:** 154 | ```bash 155 | npm run bot 156 | ``` 157 | This starts the trading bot in headless mode, connecting to AsterDEX tick stream and executing the configured strategy. 158 | 159 | ## Configuration 160 | 161 | ### Environment Variables 162 | 163 | The bot uses environment variables for configuration. Here's a comprehensive overview: 164 | 165 | #### Exchange Configuration 166 | 167 | | Variable | Description | Example | 168 | | --- | --- | --- | 169 | | `ASTER_RPC_URL` | AsterDEX REST API endpoint | `https://fapi.asterdex.com` | 170 | | `ASTER_WS_URL` | AsterDEX WebSocket endpoint | `wss://fstream.asterdex.com/ws` | 171 | | `ASTER_API_KEY` | Your AsterDEX API key | Generated from asterdex.com | 172 | | `ASTER_API_SECRET` | Your AsterDEX API secret | Generated from asterdex.com | 173 | | `ASTER_PRIVATE_KEY` | Wallet private key for trading | Your dedicated wallet key | 174 | | `PAIR_SYMBOL` | Trading pair symbol | `ASTERUSDT-PERP` | 175 | 176 | #### Strategy Configuration 177 | 178 | | Variable | Description | Default | 179 | | --- | --- | --- | 180 | | `STRATEGY_TYPE` | Strategy to use: `watermellon` or `peach-hybrid` | `peach-hybrid` | 181 | | `VIRTUAL_TIMEFRAME_MS` | Synthetic bar timeframe in milliseconds | `30000` (30 seconds) | 182 | 183 | #### Watermellon Strategy Parameters 184 | 185 | | Variable | Description | Default | 186 | | --- | --- | --- | 187 | | `EMA_FAST` | Fast EMA period | `8` | 188 | | `EMA_MID` | Mid EMA period | `21` | 189 | | `EMA_SLOW` | Slow EMA period | `48` | 190 | | `RSI_LENGTH` | RSI calculation period | `14` | 191 | | `RSI_MIN_LONG` | Minimum RSI for long signals | `42` | 192 | | `RSI_MAX_SHORT` | Maximum RSI for short signals | `58` | 193 | 194 | #### Peach Hybrid Strategy Parameters 195 | 196 | **V1 System:** 197 | - `PEACH_V1_EMA_FAST`, `PEACH_V1_EMA_MID`, `PEACH_V1_EMA_SLOW` 198 | - `PEACH_V1_EMA_MICRO_FAST`, `PEACH_V1_EMA_MICRO_SLOW` 199 | - `PEACH_V1_RSI_LENGTH`, `PEACH_V1_RSI_MIN_LONG`, `PEACH_V1_RSI_MAX_SHORT` 200 | - `PEACH_V1_MIN_BARS_BETWEEN`, `PEACH_V1_MIN_MOVE_PCT` 201 | 202 | **V2 System:** 203 | - `PEACH_V2_EMA_FAST`, `PEACH_V2_EMA_MID`, `PEACH_V2_EMA_SLOW` 204 | - `PEACH_V2_RSI_MOMENTUM_THRESHOLD` 205 | - `PEACH_V2_VOLUME_LOOKBACK`, `PEACH_V2_VOLUME_MULTIPLIER` 206 | - `PEACH_V2_EXIT_VOLUME_MULTIPLIER` 207 | 208 | #### Risk Management Parameters 209 | 210 | | Variable | Description | Default | 211 | | --- | --- | --- | 212 | | `MAX_POSITION_USDT` | Maximum position size in USDT | `10000` | 213 | | `MAX_LEVERAGE` | Maximum leverage (5x, 10x, 20x, 50x, 1000x premium) | `5` | 214 | | `MAX_FLIPS_PER_HOUR` | Maximum position flips per hour | `12` | 215 | | `MAX_POSITIONS` | Maximum concurrent positions | `1` | 216 | | `STOP_LOSS_PCT` | Stop-loss percentage from entry | `0` (disabled) | 217 | | `TAKE_PROFIT_PCT` | Take-profit percentage from entry | `0` (disabled) | 218 | | `USE_STOP_LOSS` | Enable stop-loss | `false` | 219 | | `EMERGENCY_STOP_LOSS_PCT` | Emergency stop-loss percentage | `2.0` | 220 | | `MODE` | Execution mode: `dry-run` or `live` | `dry-run` | 221 | 222 | ### Security Best Practices 223 | 224 | 1. **Dedicated Wallet**: Use a separate wallet for the bot with limited funds 225 | 2. **No Withdrawal Permissions**: Configure API keys without withdrawal permissions 226 | 3. **Environment Variables**: Never commit `.env.local` to version control 227 | 4. **Private Keys**: Store private keys securely and never share them 228 | 229 | ## Usage 230 | 231 | ### Starting the Dashboard 232 | 233 | ```bash 234 | npm run dev 235 | ``` 236 | 237 | Access the dashboard at `http://localhost:3000` to: 238 | - View trading strategy specifications 239 | - Monitor bot status 240 | - Analyze trading signals 241 | - Review performance metrics 242 | 243 | ### Running the Trading Bot 244 | 245 | ```bash 246 | npm run bot 247 | ``` 248 | 249 | The bot will: 250 | 1. Load configuration from `.env.local` 251 | 2. Connect to AsterDEX WebSocket tick stream 252 | 3. Build synthetic bars from tick data 253 | 4. Calculate technical indicators (EMA, RSI) 254 | 5. Generate trading signals based on strategy 255 | 6. Execute trades (dry-run or live based on `MODE`) 256 | 257 | ### Dry-Run Testing 258 | 259 | Before going live, always test in dry-run mode: 260 | 261 | 1. Set `MODE=dry-run` in `.env.local` 262 | 2. Run `npm run bot` 263 | 3. Monitor logs for trade signals 264 | 4. Verify strategy behavior matches expectations 265 | 5. Switch to `MODE=live` only after thorough testing 266 | 267 | ## Project Structure 268 | 269 | ``` 270 | aster-bot/ 271 | ├── src/ 272 | │ ├── app/ # Next.js dashboard application 273 | │ │ ├── api/ # API routes 274 | │ │ │ └── spec/ # Strategy specification endpoint 275 | │ │ ├── page.tsx # Dashboard page 276 | │ │ └── layout.tsx # App layout 277 | │ ├── bot/ # Headless bot entry point 278 | │ │ └── index.ts # Bot runner script 279 | │ └── lib/ # Core trading library 280 | │ ├── bot/ # Strategy orchestration 281 | │ │ └── botRunner.ts # Main bot execution logic 282 | │ ├── indicators/ # Technical indicators 283 | │ │ ├── ema.ts # Exponential Moving Average 284 | │ │ ├── rsi.ts # Relative Strength Index 285 | │ │ └── adx.ts # Average Directional Index 286 | │ ├── execution/ # Trade execution adapters 287 | │ │ ├── dryRunExecutor.ts # Dry-run execution 288 | │ │ ├── liveExecutor.ts # Live execution 289 | │ │ └── orderTracker.ts # Order tracking 290 | │ ├── rest/ # REST API client 291 | │ │ └── restPoller.ts # Polling for account data 292 | │ ├── security/ # Security utilities 293 | │ │ └── keyManager.ts # Key management 294 | │ ├── state/ # State management 295 | │ │ ├── positionState.ts # Position tracking 296 | │ │ └── statePersistence.ts # State persistence 297 | │ ├── tickStream.ts # WebSocket tick stream client 298 | │ ├── virtualBarBuilder.ts # Synthetic bar builder 299 | │ ├── watermellonEngine.ts # Watermellon strategy engine 300 | │ ├── peachHybridEngine.ts # Peach Hybrid strategy engine 301 | │ ├── config.ts # Configuration parser (Zod) 302 | │ ├── spec.ts # Strategy specifications 303 | │ └── types.ts # TypeScript type definitions 304 | ├── data/ # Bot state persistence 305 | │ └── bot-state.json # Saved bot state 306 | ├── test-data/ # Test data files 307 | ├── env.example # Environment variables template 308 | ├── package.json # Dependencies and scripts 309 | ├── tsconfig.json # TypeScript configuration 310 | └── README.md # This file 311 | ``` 312 | 313 | ## Risk Management 314 | 315 | The bot includes comprehensive risk management features: 316 | 317 | ### Position Limits 318 | - **Maximum Position Size**: Configurable in USDT to limit exposure 319 | - **Maximum Leverage**: Set leverage ceiling (5x, 10x, 20x, 50x, or 1000x for premium) 320 | - **Maximum Positions**: Limit concurrent open positions 321 | 322 | ### Stop-Loss Protection 323 | - **Regular Stop-Loss**: Percentage-based stop-loss from entry price 324 | - **Emergency Stop-Loss**: Hard stop at configured percentage (default 2%) 325 | - **Trailing Stop-Loss**: Tracks highest/lowest prices for dynamic stops 326 | 327 | ### Trading Limits 328 | - **Flip Rate Limiting**: Prevents excessive position flipping (max per hour) 329 | - **Signal Deduplication**: Avoids duplicate trade signals 330 | - **Freeze Mechanism**: Temporarily halts trading under certain conditions 331 | 332 | ### Best Practices 333 | 1. **Start Small**: Begin with minimum position sizes 334 | 2. **Use Dry-Run**: Test extensively before live trading 335 | 3. **Monitor Closely**: Watch bot performance, especially initially 336 | 4. **Set Stop-Losses**: Always configure stop-loss protection 337 | 5. **Limit Leverage**: Use conservative leverage initially 338 | 6. **Dedicated Funds**: Only use funds you can afford to lose 339 | 340 | ## FAQ 341 | 342 | ### General Questions 343 | 344 | **Q: What is AsterDEX?** 345 | A: AsterDEX is a cryptocurrency derivatives exchange. This bot is designed specifically for trading on AsterDEX's perpetual futures markets. 346 | 347 | **Q: Is this bot safe to use?** 348 | A: The bot includes risk management features, but trading cryptocurrencies carries inherent risks. Always use dry-run mode first, start with small positions, and never trade more than you can afford to lose. 349 | 350 | **Q: Can I use this bot on other exchanges?** 351 | A: Currently, the bot is designed specifically for AsterDEX. Adapting it to other exchanges would require modifications to the exchange API integration. 352 | 353 | **Q: How much can I make with this bot?** 354 | A: Trading results vary based on market conditions, strategy parameters, and risk settings. Past performance does not guarantee future results. Always test thoroughly in dry-run mode. 355 | 356 | ### Technical Questions 357 | 358 | **Q: What programming language is this written in?** 359 | A: The bot is written in **TypeScript** and uses **Next.js** for the dashboard interface. 360 | 361 | **Q: Do I need to know programming to use this?** 362 | A: Basic command-line knowledge is helpful, but the bot can be configured through environment variables without deep programming knowledge. 363 | 364 | **Q: How do I update the bot?** 365 | A: Pull the latest changes from the repository and run `npm install` to update dependencies. 366 | 367 | **Q: Can I modify the trading strategies?** 368 | A: Yes! The code is open source. You can modify strategy parameters or create custom strategies by extending the engine classes. 369 | 370 | **Q: What happens if the bot crashes?** 371 | A: The bot includes state persistence, so it can resume from the last saved state after a restart. However, always monitor the bot and ensure it's running properly. 372 | 373 | ### Trading Questions 374 | 375 | **Q: What is the Watermellon strategy?** 376 | A: The Watermellon strategy uses a triple EMA stack (8/21/48) with RSI(14) filters to identify trend-following opportunities with momentum confirmation. 377 | 378 | **Q: What is the Peach Hybrid strategy?** 379 | A: The Peach Hybrid strategy combines trend detection (V1) with momentum surge identification (V2) for more sophisticated signal generation. 380 | 381 | **Q: How often does the bot trade?** 382 | A: Trading frequency depends on market conditions and strategy parameters. The bot includes flip rate limiting to prevent overtrading. 383 | 384 | **Q: Can I backtest strategies?** 385 | A: The bot currently focuses on live/dry-run execution. For backtesting, you would need to implement historical data replay functionality. 386 | 387 | ## Contributing 388 | 389 | Contributions are welcome! If you'd like to contribute to this project: 390 | 391 | 1. Fork the repository 392 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 393 | 3. Make your changes 394 | 4. Test thoroughly in dry-run mode 395 | 5. Commit your changes (`git commit -m 'Add amazing feature'`) 396 | 6. Push to the branch (`git push origin feature/amazing-feature`) 397 | 7. Open a Pull Request 398 | 399 | ### Areas for Contribution 400 | 401 | - Additional trading strategies 402 | - Enhanced risk management features 403 | - Backtesting capabilities 404 | - Performance optimizations 405 | - Documentation improvements 406 | - Bug fixes and testing 407 | 408 | ## Keywords 409 | 410 | **Trading Bot Keywords:** 411 | - automated trading bot 412 | - cryptocurrency trading bot 413 | - algorithmic trading 414 | - crypto trading automation 415 | - trading bot for AsterDEX 416 | - scalping bot 417 | - automated crypto trading 418 | - trading strategy bot 419 | 420 | **Technical Keywords:** 421 | - EMA trading strategy 422 | - RSI indicator trading 423 | - technical analysis bot 424 | - algorithmic trading system 425 | - TypeScript trading bot 426 | - Next.js trading dashboard 427 | - WebSocket trading bot 428 | - real-time trading bot 429 | 430 | **Exchange Keywords:** 431 | - AsterDEX trading bot 432 | - AsterDEX automation 433 | - AsterDEX API integration 434 | - perpetual futures trading 435 | - derivatives trading bot 436 | 437 | **Strategy Keywords:** 438 | - Watermellon strategy 439 | - Peach Hybrid strategy 440 | - EMA crossover strategy 441 | - RSI momentum trading 442 | - trend following bot 443 | - momentum trading bot 444 | 445 | --- 446 | 447 | ## Contact 448 | 449 | For support, questions, or collaboration opportunities: 450 | 451 | - **Twitter/X**: [@kinexbt](https://x.com/kinexbt) 452 | - **Telegram**: [@kinexbt](https://t.me/kinexbt) 453 | - **Email**: piter.jb0817@gmail.com 454 | 455 | ## Donation 456 | 457 | If you find this project useful and would like to support its development: 458 | 459 | **Donation Address**: `F959q6hGMgyd9o2BehxVd1tqMz46hJthospeX1PKuhbm` 460 | 461 | --- 462 | 463 | **Disclaimer**: This software is provided for educational and research purposes. Cryptocurrency trading involves substantial risk of loss. Use at your own risk. The authors and contributors are not responsible for any financial losses incurred through the use of this software. 464 | 465 | **Support**: For issues, questions, or contributions, please open an issue on the repository. 466 | 467 | **Star this repository** if you find it useful! 468 | -------------------------------------------------------------------------------- /src/lib/bot/botRunner.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { WatermellonEngine } from "../watermellonEngine"; 3 | import { PeachHybridEngine } from "../peachHybridEngine"; 4 | import { VirtualBarBuilder } from "../virtualBarBuilder"; 5 | import { RestPoller } from "../rest/restPoller"; 6 | import { PositionStateManager } from "../state/positionState"; 7 | import { OrderTracker } from "../execution/orderTracker"; 8 | import { StatePersistence } from "../state/statePersistence"; 9 | import { KeyManager } from "../security/keyManager"; 10 | import type { 11 | AppConfig, 12 | ExecutionAdapter, 13 | PeachConfig, 14 | PositionState, 15 | StrategySignal, 16 | SyntheticBar, 17 | Tick, 18 | WatermellonConfig, 19 | } from "../types"; 20 | 21 | type BotRunnerEvents = { 22 | signal: (signal: StrategySignal, bar: SyntheticBar) => void; 23 | position: (position: PositionState) => void; 24 | log: (message: string, payload?: Record) => void; 25 | stop: () => void; 26 | }; 27 | 28 | type TickStream = { 29 | start: () => Promise; 30 | stop: () => Promise; 31 | on: (event: K, handler: (...args: unknown[]) => void) => () => void; 32 | }; 33 | 34 | const HOUR_MS = 60 * 60 * 1000; 35 | 36 | type TradeRecord = { 37 | id: string; 38 | side: "long" | "short"; 39 | entryPrice: number; 40 | exitPrice: number; 41 | entryTime: number; 42 | exitTime: number; 43 | size: number; 44 | pnl: number; 45 | pnlPercent: number; 46 | reason: string; 47 | leverage: number; 48 | }; 49 | 50 | class TradeStatistics { 51 | private trades: TradeRecord[] = []; 52 | private currentTrade: Partial | null = null; 53 | 54 | startTrade(side: "long" | "short", entryPrice: number, size: number, leverage: number): void { 55 | this.currentTrade = { 56 | id: `trade-${Date.now()}`, 57 | side, 58 | entryPrice, 59 | entryTime: Date.now(), 60 | size, 61 | leverage, 62 | }; 63 | } 64 | 65 | closeTrade(exitPrice: number, reason: string): void { 66 | if (!this.currentTrade) return; 67 | 68 | const trade: TradeRecord = { 69 | ...this.currentTrade, 70 | exitPrice, 71 | exitTime: Date.now(), 72 | pnl: this.calculatePnL(this.currentTrade as TradeRecord, exitPrice), 73 | pnlPercent: this.calculatePnLPercent(this.currentTrade as TradeRecord, exitPrice), 74 | reason, 75 | } as TradeRecord; 76 | 77 | this.trades.push(trade); 78 | this.currentTrade = null; 79 | } 80 | 81 | private calculatePnL(trade: TradeRecord, exitPrice: number): number { 82 | const priceDiff = trade.side === "long" ? exitPrice - trade.entryPrice : trade.entryPrice - exitPrice; 83 | return priceDiff * trade.size; 84 | } 85 | 86 | private calculatePnLPercent(trade: TradeRecord, exitPrice: number): number { 87 | const priceDiff = trade.side === "long" ? exitPrice - trade.entryPrice : trade.entryPrice - exitPrice; 88 | return (priceDiff / trade.entryPrice) * 100 * trade.leverage; 89 | } 90 | 91 | getStats(): { 92 | totalTrades: number; 93 | winningTrades: number; 94 | losingTrades: number; 95 | winRate: number; 96 | totalPnL: number; 97 | avgWin: number; 98 | avgLoss: number; 99 | profitFactor: number; 100 | maxDrawdown: number; 101 | largestWin: number; 102 | largestLoss: number; 103 | } { 104 | if (this.trades.length === 0) { 105 | return { 106 | totalTrades: 0, 107 | winningTrades: 0, 108 | losingTrades: 0, 109 | winRate: 0, 110 | totalPnL: 0, 111 | avgWin: 0, 112 | avgLoss: 0, 113 | profitFactor: 0, 114 | maxDrawdown: 0, 115 | largestWin: 0, 116 | largestLoss: 0, 117 | }; 118 | } 119 | 120 | const winningTrades = this.trades.filter(t => t.pnl > 0); 121 | const losingTrades = this.trades.filter(t => t.pnl < 0); 122 | 123 | const totalPnL = this.trades.reduce((sum, t) => sum + t.pnl, 0); 124 | const avgWin = winningTrades.length > 0 ? winningTrades.reduce((sum, t) => sum + t.pnl, 0) / winningTrades.length : 0; 125 | const avgLoss = losingTrades.length > 0 ? Math.abs(losingTrades.reduce((sum, t) => sum + t.pnl, 0) / losingTrades.length) : 0; 126 | 127 | // Calculate drawdown 128 | let peak = 0; 129 | let maxDrawdown = 0; 130 | let runningPnL = 0; 131 | 132 | for (const trade of this.trades) { 133 | runningPnL += trade.pnl; 134 | if (runningPnL > peak) { 135 | peak = runningPnL; 136 | } 137 | const drawdown = peak - runningPnL; 138 | if (drawdown > maxDrawdown) { 139 | maxDrawdown = drawdown; 140 | } 141 | } 142 | 143 | const largestWin = winningTrades.length > 0 ? Math.max(...winningTrades.map(t => t.pnl)) : 0; 144 | const largestLoss = losingTrades.length > 0 ? Math.min(...losingTrades.map(t => t.pnl)) : 0; 145 | 146 | return { 147 | totalTrades: this.trades.length, 148 | winningTrades: winningTrades.length, 149 | losingTrades: losingTrades.length, 150 | winRate: (winningTrades.length / this.trades.length) * 100, 151 | totalPnL, 152 | avgWin, 153 | avgLoss, 154 | profitFactor: avgLoss > 0 ? (avgWin * winningTrades.length) / (avgLoss * losingTrades.length) : avgWin > 0 ? Infinity : 0, 155 | maxDrawdown, 156 | largestWin, 157 | largestLoss, 158 | }; 159 | } 160 | 161 | getRecentTrades(limit = 10): TradeRecord[] { 162 | return this.trades.slice(-limit); 163 | } 164 | } 165 | 166 | export class BotRunner { 167 | private readonly emitter = new EventEmitter(); 168 | private readonly barBuilder: VirtualBarBuilder; 169 | private readonly engine: WatermellonEngine | PeachHybridEngine; 170 | private readonly restPoller: RestPoller; 171 | private readonly stateManager: PositionStateManager; 172 | private readonly orderTracker: OrderTracker; 173 | private readonly statePersistence: StatePersistence; 174 | private readonly tradeStats = new TradeStatistics(); 175 | private position: PositionState = { side: "flat", size: 0 }; 176 | private flipHistory: number[] = []; 177 | private unsubscribers: Array<() => void> = []; 178 | private tradingFrozen = false; 179 | private freezeUntil = 0; 180 | private processedSignals = new Set(); // Event deduplication 181 | private lastBarCloseTime = 0; 182 | private readonly isPeachHybrid: boolean; 183 | // Trailing stop loss tracking 184 | private highestPrice: number | null = null; // For long positions 185 | private lowestPrice: number | null = null; // For short positions 186 | 187 | constructor( 188 | private readonly config: AppConfig, 189 | private readonly tickStream: TickStream, 190 | private readonly executor: ExecutionAdapter, 191 | ) { 192 | this.isPeachHybrid = config.strategyType === "peach-hybrid"; 193 | const timeframeMs = this.isPeachHybrid 194 | ? (config.strategy as PeachConfig).timeframeMs 195 | : (config.strategy as WatermellonConfig).timeframeMs; 196 | 197 | this.barBuilder = new VirtualBarBuilder(timeframeMs); 198 | 199 | // Initialize appropriate engine 200 | if (this.isPeachHybrid) { 201 | this.engine = new PeachHybridEngine(config.strategy as PeachConfig); 202 | } else { 203 | this.engine = new WatermellonEngine(config.strategy as WatermellonConfig); 204 | } 205 | 206 | this.restPoller = new RestPoller(config.credentials); 207 | this.stateManager = new PositionStateManager(); 208 | this.orderTracker = new OrderTracker(); 209 | this.statePersistence = new StatePersistence(); 210 | this.loadWarmState(); 211 | } 212 | 213 | private loadWarmState(): void { 214 | const saved = this.statePersistence.load(); 215 | if (saved) { 216 | this.lastBarCloseTime = saved.lastBarCloseTime; 217 | this.stateManager.updateLocalState(saved.position); 218 | this.position = { 219 | side: saved.position.side, 220 | size: saved.position.size, 221 | entryPrice: saved.position.avgEntry > 0 ? saved.position.avgEntry : undefined, 222 | }; 223 | this.log("Warm state loaded", { 224 | position: saved.position.side, 225 | size: saved.position.size, 226 | lastBarClose: new Date(saved.lastBarCloseTime).toISOString(), 227 | }); 228 | } 229 | } 230 | 231 | private saveState(): void { 232 | const state = this.stateManager.getState(); 233 | this.statePersistence.save({ 234 | position: state, 235 | lastBarCloseTime: this.lastBarCloseTime, 236 | }); 237 | } 238 | 239 | async start() { 240 | this.subscribe(); 241 | this.startRestPolling(); 242 | 243 | // Wait a moment for initial balance fetch 244 | this.log("Waiting for initial balance fetch..."); 245 | await new Promise((resolve) => setTimeout(resolve, 2000)); 246 | 247 | // Log initial balance status - make it very visible 248 | if (this.usdtBalance > 0) { 249 | this.log(" Bot started with USDT balance", { 250 | availableUSDT: this.usdtBalance.toFixed(4), 251 | maxPositionSize: this.config.risk.maxPositionSize, 252 | maxLeverage: this.config.risk.maxLeverage, 253 | }); 254 | } else { 255 | this.log(" Bot started but USDT balance is 0 or not yet loaded", { 256 | currentBalance: this.usdtBalance.toFixed(4), 257 | maxPositionSize: this.config.risk.maxPositionSize, 258 | maxLeverage: this.config.risk.maxLeverage, 259 | note: "Balance will update when REST poller receives data", 260 | }); 261 | } 262 | 263 | await this.tickStream.start(); 264 | const timeframeMs = this.isPeachHybrid 265 | ? (this.config.strategy as PeachConfig).timeframeMs 266 | : (this.config.strategy as WatermellonConfig).timeframeMs; 267 | this.log("BotRunner started", { timeframeMs }); 268 | } 269 | 270 | async stop() { 271 | this.restPoller.stop(); 272 | await this.tickStream.stop(); 273 | this.unsubscribers.forEach((off) => off()); 274 | this.unsubscribers = []; 275 | this.emitter.emit("stop"); 276 | } 277 | 278 | private usdtBalance: number = 0; 279 | private lastBalanceLog: number = 0; 280 | 281 | private startRestPolling(): void { 282 | this.restPoller.on("position", (position) => { 283 | const reconciled = this.stateManager.updateFromRest({ 284 | positionAmt: position.positionAmt, 285 | entryPrice: position.entryPrice || "0", 286 | unrealizedProfit: position.unRealizedProfit || "0", 287 | }); 288 | 289 | if (!reconciled) { 290 | this.log("State reconciliation failed", { 291 | shouldFreeze: this.stateManager.shouldFreezeTrading(), 292 | }); 293 | if (this.stateManager.shouldFreezeTrading()) { 294 | this.freezeTrading(60_000); // Freeze for 60 seconds 295 | } 296 | } else { 297 | // Confirm orders based on position changes 298 | const size = parseFloat(position.positionAmt); 299 | if (size !== 0) { 300 | const side = size > 0 ? "long" : "short"; 301 | this.orderTracker.confirmByPositionChange(side, Math.abs(size)); 302 | } else { 303 | // If position is flat, clear any pending orders 304 | this.stateManager.clearPendingOrder(); 305 | } 306 | 307 | // Update local position state 308 | const state = this.stateManager.getState(); 309 | this.position = { 310 | side: state.side, 311 | size: state.size, 312 | entryPrice: state.avgEntry > 0 ? state.avgEntry : undefined, 313 | openedAt: state.lastUpdate, 314 | }; 315 | } 316 | }); 317 | 318 | this.restPoller.on("balance", (balances) => { 319 | if (!balances || !Array.isArray(balances) || balances.length === 0) { 320 | this.log("⚠️ WARNING: Empty or invalid balance response", { balances }); 321 | return; 322 | } 323 | 324 | // Try multiple variations of USDT asset name (case-insensitive) 325 | const usdtBalance = balances.find((b) => { 326 | const asset = (b.asset || "").toUpperCase(); 327 | return asset === "USDT"; 328 | }); 329 | 330 | if (usdtBalance) { 331 | const availableStr = usdtBalance.availableBalance || usdtBalance.balance || "0"; 332 | const totalStr = usdtBalance.balance || usdtBalance.availableBalance || "0"; 333 | const newBalance = parseFloat(availableStr); 334 | const totalBalance = parseFloat(totalStr); 335 | 336 | // Only log if balance changed significantly or periodically (every 60 seconds) 337 | const now = Date.now(); 338 | const balanceChanged = Math.abs(newBalance - this.usdtBalance) > 0.01; 339 | 340 | if (balanceChanged || !this.lastBalanceLog || now - this.lastBalanceLog > 60000) { 341 | this.log("USDT Balance", { 342 | available: newBalance.toFixed(4), 343 | total: totalBalance.toFixed(4), 344 | changed: balanceChanged, 345 | }); 346 | this.lastBalanceLog = now; 347 | } 348 | 349 | this.usdtBalance = newBalance; 350 | } else { 351 | // Log warning if USDT balance not found 352 | this.log("⚠️ WARNING: USDT balance not found", { 353 | assetsFound: balances.map((b) => b.asset).slice(0, 5), 354 | }); 355 | } 356 | }); 357 | 358 | this.restPoller.on("error", (error) => { 359 | this.log("REST poller error", { error: error.message }); 360 | }); 361 | 362 | // Poll every 2 seconds (1-3s range as per requirements) 363 | this.log("Starting REST polling for position/balance reconciliation", { 364 | intervalMs: 2000, 365 | endpoint: `${this.config.credentials.rpcUrl}/fapi/v2/account` 366 | }); 367 | this.restPoller.start(2000); 368 | } 369 | 370 | private freezeTrading(durationMs: number): void { 371 | this.tradingFrozen = true; 372 | this.freezeUntil = Date.now() + durationMs; 373 | this.log("Trading frozen due to reconciliation failures", { durationMs, freezeUntil: this.freezeUntil }); 374 | setTimeout(() => { 375 | this.tradingFrozen = false; 376 | this.stateManager.resetReconciliationFailures(); 377 | this.log("Trading unfrozen"); 378 | }, durationMs); 379 | } 380 | 381 | on(event: K, handler: BotRunnerEvents[K]): () => void { 382 | this.emitter.on(event, handler); 383 | return () => this.emitter.off(event, handler); 384 | } 385 | 386 | private subscribe() { 387 | const offTick = this.tickStream.on("tick", (tick: unknown) => { 388 | if (tick && typeof tick === "object" && "price" in tick && "timestamp" in tick) { 389 | this.handleTick(tick as Tick); 390 | } 391 | }); 392 | const offError = this.tickStream.on("error", (error: unknown) => { 393 | const err = error instanceof Error ? error : new Error(String(error)); 394 | this.log("Tick stream error", { error: err }); 395 | }); 396 | const offClose = this.tickStream.on("close", () => this.log("Tick stream closed")); 397 | this.unsubscribers.push(offTick, offError, offClose); 398 | } 399 | 400 | private handleTick(tick: Tick) { 401 | const { closedBar } = this.barBuilder.pushTick(tick); 402 | if (closedBar) { 403 | this.evaluateProtectiveExits(closedBar); 404 | this.handleBarClose(closedBar); 405 | } 406 | } 407 | 408 | private handleBarClose(bar: SyntheticBar) { 409 | // Deduplication: prevent processing same bar multiple times 410 | if (bar.endTime <= this.lastBarCloseTime) { 411 | return; 412 | } 413 | this.lastBarCloseTime = bar.endTime; 414 | 415 | // Check if trading is frozen 416 | if (this.tradingFrozen) { 417 | if (Date.now() < this.freezeUntil) { 418 | this.log("Skipping signal - trading frozen", { freezeUntil: this.freezeUntil }); 419 | return; 420 | } 421 | this.tradingFrozen = false; 422 | } 423 | 424 | // Check Peach exit conditions first (before checking for new signals) 425 | if (this.position.side !== "flat" && this.isPeachHybrid) { 426 | const exitSignal = (this.engine as PeachHybridEngine).checkExitConditions(bar); 427 | if (exitSignal.shouldExit) { 428 | this.log("Peach exit condition triggered", { reason: exitSignal.reason, details: exitSignal.details }); 429 | this.closePosition(exitSignal.reason, exitSignal.details); 430 | return; 431 | } 432 | } 433 | 434 | // Update engine with bar (Peach needs full bar, Watermellon just needs close) 435 | const signal = this.isPeachHybrid 436 | ? (this.engine as PeachHybridEngine).update(bar) 437 | : (this.engine as WatermellonEngine).update(bar.close); 438 | 439 | // Log indicator values less frequently (every 10 bars) to reduce noise 440 | if (this.isPeachHybrid && this.lastBarCloseTime % 10 === 0) { 441 | const indicators = (this.engine as PeachHybridEngine).getIndicatorValues(); 442 | const { requireTrendingMarket, adxThreshold } = this.config.risk; 443 | const adxReady = indicators.adx !== null; 444 | const marketRegimeOk = !requireTrendingMarket || (adxReady && (this.engine as PeachHybridEngine).shouldAllowTrading(adxThreshold)); 445 | 446 | this.log("Peach indicators updated", { 447 | price: bar.close.toFixed(4), 448 | volume: bar.volume.toFixed(2), 449 | v1: { 450 | emaFast: indicators.v1.emaFast?.toFixed(4), 451 | emaMid: indicators.v1.emaMid?.toFixed(4), 452 | emaSlow: indicators.v1.emaSlow?.toFixed(4), 453 | rsi: indicators.v1.rsi?.toFixed(2), 454 | }, 455 | v2: { 456 | emaFast: indicators.v2.emaFast?.toFixed(4), 457 | emaMid: indicators.v2.emaMid?.toFixed(4), 458 | emaSlow: indicators.v2.emaSlow?.toFixed(4), 459 | rsi: indicators.v2.rsi?.toFixed(2), 460 | }, 461 | adx: adxReady ? indicators.adx?.toFixed(2) : 'warming up...', 462 | marketRegime: requireTrendingMarket ? (adxReady ? (marketRegimeOk ? 'trending' : 'ranging') : 'warming up') : 'ignored', 463 | }); 464 | } else if (!this.isPeachHybrid && this.lastBarCloseTime % 10 === 0) { 465 | // Watermellon strategy logging 466 | const indicators = (this.engine as WatermellonEngine).getIndicatorValues(); 467 | if (indicators.emaFast !== null && indicators.emaMid !== null && indicators.emaSlow !== null && indicators.rsi !== null) { 468 | const bullStack = indicators.emaFast > indicators.emaMid && indicators.emaMid > indicators.emaSlow; 469 | const bearStack = indicators.emaFast < indicators.emaMid && indicators.emaMid < indicators.emaSlow; 470 | 471 | this.log("Watermellon indicators updated", { 472 | price: bar.close.toFixed(4), 473 | emaFast: indicators.emaFast.toFixed(4), 474 | emaMid: indicators.emaMid.toFixed(4), 475 | emaSlow: indicators.emaSlow.toFixed(4), 476 | rsi: indicators.rsi.toFixed(2), 477 | bullStack, 478 | bearStack, 479 | }); 480 | } 481 | } 482 | 483 | if (!signal) { 484 | return; 485 | } 486 | 487 | // Event deduplication: create unique key for signal 488 | const signalKey = `${signal.type}-${bar.endTime}`; 489 | if (this.processedSignals.has(signalKey)) { 490 | return; 491 | } 492 | this.processedSignals.add(signalKey); 493 | // Clean old signals (keep last 100) 494 | if (this.processedSignals.size > 100) { 495 | const first = this.processedSignals.values().next().value; 496 | if (first) { 497 | this.processedSignals.delete(first); 498 | } 499 | } 500 | 501 | this.emitter.emit("signal", signal, bar); 502 | this.log("Signal emitted", { type: signal.type, reason: signal.reason, close: bar.close }); 503 | this.applySignal(signal, bar); 504 | } 505 | 506 | private applySignal(signal: StrategySignal, bar: SyntheticBar) { 507 | if (!signal) return; 508 | 509 | // Check market regime filter for Peach Hybrid 510 | if (this.isPeachHybrid) { 511 | const { requireTrendingMarket, adxThreshold } = this.config.risk; 512 | if (requireTrendingMarket && !(this.engine as PeachHybridEngine).shouldAllowTrading(adxThreshold)) { 513 | this.log("Skipping signal - market not trending", { 514 | adx: (this.engine as PeachHybridEngine).getIndicatorValues().adx?.toFixed(2), 515 | threshold: adxThreshold, 516 | }); 517 | return; 518 | } 519 | } 520 | 521 | const timestamp = bar.endTime; 522 | const { maxPositionSize, maxLeverage, positionSizePct } = this.config.risk; 523 | 524 | // Calculate position size: use percentage of balance if configured, otherwise fixed amount 525 | let size: number; 526 | if (positionSizePct && positionSizePct > 0) { 527 | // Use percentage of available balance, considering leverage 528 | // Add safety buffer: use 70% of calculated amount to account for fees, slippage, and margin requirements 529 | const availableForPosition = this.usdtBalance * (positionSizePct / 100) * 0.7; 530 | size = Math.min(availableForPosition * maxLeverage, maxPositionSize); 531 | // Ensure reasonable position size bounds 532 | size = Math.max(size, 5); // Minimum 5 units 533 | size = Math.min(size, 500); // Maximum 500 units to prevent over-leveraging 534 | } else { 535 | size = maxPositionSize; 536 | } 537 | const order = { 538 | size, 539 | leverage: maxLeverage, 540 | price: bar.close, 541 | signalReason: signal.reason, 542 | timestamp, 543 | side: signal.type, 544 | } as const; 545 | 546 | if (signal.type === "long") { 547 | if (this.position.side === "long") { 548 | return; 549 | } 550 | if (!this.canFlip(timestamp)) { 551 | return this.log("Flip budget exhausted, ignoring long signal"); 552 | } 553 | if (this.position.side === "short") { 554 | this.closePosition("flip-long", { price: bar.close }); 555 | } 556 | this.enterPosition("long", order); 557 | return; 558 | } 559 | 560 | if (signal.type === "short") { 561 | if (this.position.side === "short") { 562 | return; 563 | } 564 | if (!this.canFlip(timestamp)) { 565 | return this.log("Flip budget exhausted, ignoring short signal"); 566 | } 567 | if (this.position.side === "long") { 568 | this.closePosition("flip-short", { price: bar.close }); 569 | } 570 | this.enterPosition("short", order); 571 | } 572 | } 573 | 574 | private async enterPosition(side: "long" | "short", order: Parameters[0]) { 575 | // Check balance before placing order (with leverage consideration) 576 | const requiredMargin = order.size / order.leverage; 577 | 578 | // Log balance check for debugging 579 | this.log("Checking balance before entering position", { 580 | side, 581 | requiredMargin: requiredMargin.toFixed(4), 582 | availableBalance: this.usdtBalance.toFixed(4), 583 | orderSize: order.size, 584 | leverage: order.leverage, 585 | sufficient: this.usdtBalance >= requiredMargin, 586 | }); 587 | 588 | if (this.usdtBalance < requiredMargin) { 589 | this.log("❌ Insufficient balance to enter position", { 590 | required: requiredMargin.toFixed(4), 591 | available: this.usdtBalance.toFixed(4), 592 | shortfall: (requiredMargin - this.usdtBalance).toFixed(4), 593 | orderSize: order.size, 594 | leverage: order.leverage, 595 | }); 596 | return; 597 | } 598 | 599 | try { 600 | if (side === "long") { 601 | await this.executor.enterLong(order); 602 | } else { 603 | await this.executor.enterShort(order); 604 | } 605 | } catch (error) { 606 | const err = error instanceof Error ? error : new Error(String(error)); 607 | // Handle insufficient balance errors gracefully 608 | if (err.message.includes("balance") || err.message.includes("insufficient") || err.message.includes("-2019")) { 609 | this.log("Order failed: Insufficient balance", { 610 | error: err.message, 611 | required: requiredMargin, 612 | available: this.usdtBalance, 613 | }); 614 | return; // Don't throw, just log and skip 615 | } 616 | // Re-throw other errors 617 | throw error; 618 | } 619 | 620 | // Track order for confirmation 621 | const orderId = `order-${order.timestamp}`; 622 | this.orderTracker.trackOrder(order, orderId); 623 | this.stateManager.setPendingOrder({ 624 | side, 625 | size: order.size, 626 | timestamp: order.timestamp, 627 | }); 628 | 629 | // Update local state optimistically 630 | this.position = { 631 | side, 632 | size: order.size, 633 | entryPrice: order.price, 634 | openedAt: order.timestamp, 635 | }; 636 | // Reset trailing stop tracking 637 | this.highestPrice = side === "long" ? order.price : null; 638 | this.lowestPrice = side === "short" ? order.price : null; 639 | this.stateManager.updateLocalState({ 640 | side, 641 | size: order.size, 642 | avgEntry: order.price, 643 | }); 644 | 645 | // Update engine position state for exit logic 646 | if (this.isPeachHybrid) { 647 | (this.engine as PeachHybridEngine).setPosition(side); 648 | } 649 | 650 | // Start tracking trade statistics 651 | this.tradeStats.startTrade(side, order.price, order.size, order.leverage); 652 | 653 | this.recordFlip(order.timestamp); 654 | this.emitter.emit("position", this.position); 655 | } 656 | 657 | private async closePosition(reason: string, meta?: Record) { 658 | if (this.position.side === "flat") { 659 | return; 660 | } 661 | 662 | // Record trade exit price before closing 663 | const exitPrice = meta && typeof meta === 'object' && 'close' in meta 664 | ? Number(meta.close) 665 | : meta && typeof meta === 'object' && 'price' in meta 666 | ? Number(meta.price) 667 | : this.position.entryPrice || 0; 668 | 669 | await this.executor.closePosition(reason, meta); 670 | 671 | // Complete trade statistics 672 | this.tradeStats.closeTrade(exitPrice, reason); 673 | 674 | // Update engine position state 675 | if (this.isPeachHybrid) { 676 | (this.engine as PeachHybridEngine).setPosition("flat"); 677 | } 678 | 679 | // Log trade statistics periodically 680 | this.logTradeStats(); 681 | 682 | // Reset trailing stop tracking 683 | this.highestPrice = null; 684 | this.lowestPrice = null; 685 | this.position = { side: "flat", size: 0 }; 686 | this.emitter.emit("position", this.position); 687 | } 688 | 689 | private logTradeStats(): void { 690 | const stats = this.tradeStats.getStats(); 691 | if (stats.totalTrades > 0) { 692 | this.log("📊 Trade Statistics", { 693 | totalTrades: stats.totalTrades, 694 | winRate: `${stats.winRate.toFixed(1)}%`, 695 | totalPnL: stats.totalPnL.toFixed(4), 696 | profitFactor: stats.profitFactor.toFixed(2), 697 | maxDrawdown: stats.maxDrawdown.toFixed(4), 698 | avgWin: stats.avgWin.toFixed(4), 699 | avgLoss: stats.avgLoss.toFixed(4), 700 | }); 701 | } 702 | } 703 | 704 | private evaluateProtectiveExits(bar: SyntheticBar) { 705 | if (this.position.side === "flat" || !this.position.entryPrice) { 706 | return; 707 | } 708 | const { stopLossPct, takeProfitPct, emergencyStopLoss, useStopLoss } = this.config.risk; 709 | const { close } = bar; 710 | 711 | // Update trailing stop prices 712 | if (this.position.side === "long") { 713 | if (this.highestPrice === null || close > this.highestPrice) { 714 | this.highestPrice = close; 715 | } 716 | } else if (this.position.side === "short") { 717 | if (this.lowestPrice === null || close < this.lowestPrice) { 718 | this.lowestPrice = close; 719 | } 720 | } 721 | 722 | // Trailing Stop Loss (for Peach Hybrid with profit > 0.5%) 723 | if (this.isPeachHybrid) { 724 | const trailingStopPct = 0.5; // 0.5% trailing stop 725 | if (this.position.side === "long" && this.highestPrice !== null) { 726 | const currentProfit = ((close - this.position.entryPrice) / this.position.entryPrice) * 100; 727 | if (currentProfit > 0.5) { 728 | // Only activate trailing stop after 0.5% profit 729 | const trailingStopPrice = this.highestPrice * (1 - trailingStopPct / 100); 730 | if (close <= trailingStopPrice) { 731 | this.log("Trailing stop-loss triggered", { 732 | trailingStopPrice: trailingStopPrice.toFixed(4), 733 | highestPrice: this.highestPrice.toFixed(4), 734 | currentProfit: currentProfit.toFixed(2) + '%', 735 | close 736 | }); 737 | this.closePosition("trailing-stop", { close, trailingStopPrice, highestPrice: this.highestPrice }); 738 | return; 739 | } 740 | } 741 | } else if (this.position.side === "short" && this.lowestPrice !== null) { 742 | const currentProfit = ((this.position.entryPrice - close) / this.position.entryPrice) * 100; 743 | if (currentProfit > 0.5) { 744 | // Only activate trailing stop after 0.5% profit 745 | const trailingStopPrice = this.lowestPrice * (1 + trailingStopPct / 100); 746 | if (close >= trailingStopPrice) { 747 | this.log("Trailing stop-loss triggered", { 748 | trailingStopPrice: trailingStopPrice.toFixed(4), 749 | lowestPrice: this.lowestPrice.toFixed(4), 750 | currentProfit: currentProfit.toFixed(2) + '%', 751 | close 752 | }); 753 | this.closePosition("trailing-stop", { close, trailingStopPrice, lowestPrice: this.lowestPrice }); 754 | return; 755 | } 756 | } 757 | } 758 | } 759 | 760 | // Emergency Stop Loss (always active for Peach Hybrid, or if useStopLoss is true) 761 | if (emergencyStopLoss && emergencyStopLoss > 0 && (this.isPeachHybrid || useStopLoss)) { 762 | const emergencyThreshold = 763 | this.position.side === "long" 764 | ? this.position.entryPrice * (1 - emergencyStopLoss / 100) 765 | : this.position.entryPrice * (1 + emergencyStopLoss / 100); 766 | 767 | if ((this.position.side === "long" && close <= emergencyThreshold) || (this.position.side === "short" && close >= emergencyThreshold)) { 768 | this.log("Emergency stop-loss triggered", { threshold: emergencyThreshold, close, entryPrice: this.position.entryPrice }); 769 | this.closePosition("emergency-stop", { close, threshold: emergencyThreshold }); 770 | return; 771 | } 772 | } 773 | 774 | // Regular Stop Loss (if enabled) 775 | if (stopLossPct && stopLossPct > 0 && useStopLoss) { 776 | const threshold = 777 | this.position.side === "long" 778 | ? this.position.entryPrice * (1 - stopLossPct / 100) 779 | : this.position.entryPrice * (1 + stopLossPct / 100); 780 | 781 | if ((this.position.side === "long" && close <= threshold) || (this.position.side === "short" && close >= threshold)) { 782 | this.log("Stop-loss triggered", { threshold, close }); 783 | this.closePosition("stop-loss", { close, threshold }); 784 | return; 785 | } 786 | } 787 | 788 | // Take Profit (scaled exits: take 50% at 1%, 30% at 2%, rest at 3%) 789 | if (takeProfitPct && takeProfitPct > 0) { 790 | const profitPct = this.position.side === "long" 791 | ? ((close - this.position.entryPrice) / this.position.entryPrice) * 100 792 | : ((this.position.entryPrice - close) / this.position.entryPrice) * 100; 793 | 794 | // For now, use simple take profit (can be enhanced with partial exits later) 795 | const target = 796 | this.position.side === "long" 797 | ? this.position.entryPrice * (1 + takeProfitPct / 100) 798 | : this.position.entryPrice * (1 - takeProfitPct / 100); 799 | 800 | if ((this.position.side === "long" && close >= target) || (this.position.side === "short" && close <= target)) { 801 | this.log("Take-profit triggered", { target, close, profitPct: profitPct.toFixed(2) + '%' }); 802 | this.closePosition("take-profit", { close, target }); 803 | } 804 | } 805 | } 806 | 807 | private canFlip(timestamp: number): boolean { 808 | const windowStart = timestamp - HOUR_MS; 809 | this.flipHistory = this.flipHistory.filter((t) => t >= windowStart); 810 | if (this.flipHistory.length >= this.config.risk.maxFlipsPerHour) { 811 | return false; 812 | } 813 | return true; 814 | } 815 | 816 | private recordFlip(timestamp: number) { 817 | this.flipHistory.push(timestamp); 818 | } 819 | 820 | private log(message: string, payload?: Record) { 821 | this.emitter.emit("log", message, payload); 822 | // Save state periodically on important events 823 | if (message.includes("position") || message.includes("signal") || message.includes("reconciliation")) { 824 | this.saveState(); 825 | } 826 | // Use KeyManager to ensure keys are never logged 827 | if (payload) { 828 | KeyManager.safeLog(`[BotRunner] ${message}`, payload); 829 | } else { 830 | console.log(`[BotRunner] ${message}`); 831 | } 832 | } 833 | } 834 | 835 | --------------------------------------------------------------------------------