├── 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 | [](https://www.typescriptlang.org/)
4 | [](https://nextjs.org/)
5 | [](https://nodejs.org/)
6 | [](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 |
--------------------------------------------------------------------------------