diff --git a/bun.lock b/bun.lock index 45c987e..8d6780a 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "ritmex-bot", @@ -14,6 +15,7 @@ "ethereum-cryptography": "^2.1.3", "ink": "^6.3.1", "react": "^19.1.1", + "trading-signals": "^7.4.3", "viem": "^2.43.1", "ws": "^8.18.3", }, @@ -381,6 +383,8 @@ "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + "trading-signals": ["trading-signals@7.4.3", "", {}, "sha512-kOyzd85qhuhU7yGkB5z74MVP6j30xBCfZy5+bgBnvfxY1ZdvaQsdD+C5j+CoIRtCAgSOuxB7jmPpBrqO6h17sQ=="], + "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], diff --git a/docs/swingtrading/binance-kline.md b/docs/swingtrading/binance-kline.md new file mode 100644 index 0000000..534e3c8 --- /dev/null +++ b/docs/swingtrading/binance-kline.md @@ -0,0 +1,369 @@ +- The following base endpoints are available. Please use whichever works best for your setup: + - **[https://api.binance.com](https://api.binance.com/)** + - **[https://api-gcp.binance.com](https://api-gcp.binance.com/)** + - **[https://api1.binance.com](https://api1.binance.com/)** + - **[https://api2.binance.com](https://api2.binance.com/)** + - **[https://api3.binance.com](https://api3.binance.com/)** + - **[https://api4.binance.com](https://api4.binance.com/)** +- The last 4 endpoints in the point above (`api1` - `api4`) should give better performance but have less stability. +- Responses are in JSON by default. To receive responses in SBE, refer to the [SBE FAQ](https://developers.binance.com/docs/binance-spot-api-docs/faqs/sbe_faq) page. +- If your request contains a symbol name containing non-ASCII characters, then the response may contain non-ASCII characters encoded in UTF-8. +- Some endpoints may return asset and/or symbol names containing non-ASCII characters encoded in UTF-8 even if the request did not contain non-ASCII characters. +- Data is returned in **chronological order**, unless noted otherwise. + - Without `startTime` or `endTime`, returns the most recent items up to the limit. + - With `startTime`, returns oldest items from `startTime` up to the limit. + - With `endTime`, returns most recent items up to `endTime` and the limit. + - With both, behaves like `startTime` but does not exceed `endTime`. +- All time and timestamp related fields in the JSON responses are in **milliseconds by default.** To receive the information in microseconds, please add the header `X-MBX-TIME-UNIT:MICROSECOND` or `X-MBX-TIME-UNIT:microsecond`. +- We support HMAC, RSA, and Ed25519 keys. For more information, please see [API Key types](https://developers.binance.com/docs/binance-spot-api-docs/faqs/api_key_types). +- Timestamp parameters (e.g. `startTime`, `endTime`, `timestamp`) can be passed in milliseconds or microseconds. +- For APIs that only send public market data, please use the base endpoint **[https://data-api.binance.vision](https://data-api.binance.vision/)**. Please refer to [Market Data Only](https://developers.binance.com/docs/binance-spot-api-docs/faqs/market_data_only) page. +- If there are enums or terms you want clarification on, please see the [SPOT Glossary](https://developers.binance.com/docs/binance-spot-api-docs/faqs/spot_glossary) for more information. +- APIs have a timeout of 10 seconds when processing a request. If a response from the Matching Engine takes longer than this, the API responds with "Timeout waiting for response from backend server. Send status unknown; execution status unknown." [(-1007 TIMEOUT)](https://developers.binance.com/docs/binance-spot-api-docs/errors#-1007-timeout) + - This does not always mean that the request failed in the Matching Engine. + - If the status of the request has not appeared in [User Data Stream](https://developers.binance.com/docs/binance-spot-api-docs/user-data-stream), please perform an API query for its status. +- **Please avoid SQL keywords in requests** as they may trigger a security block by a WAF (Web Application Firewall) rule. See [https://www.binance.com/en/support/faq/detail/360004492232](https://www.binance.com/en/support/faq/detail/360004492232) for more details. +- If your request contains a symbol name containing non-ASCII characters, then the response may contain non-ASCII characters encoded in UTF-8. +- Some endpoints may return asset and/or symbol names containing non-ASCII characters encoded in UTF-8 even if the request did not contain non-ASCII characters. + +Kline/Candlestick data +GET /api/v3/klines + +Kline/candlestick bars for a symbol. Klines are uniquely identified by their open time. + +Weight: 2 + +Parameters: + +Name Type Mandatory Description +symbol STRING YES +interval ENUM YES +startTime LONG NO +endTime LONG NO +timeZone STRING NO Default: 0 (UTC) +limit INT NO Default: 500; Maximum: 1000. +Supported kline intervals (case-sensitive): + +Interval interval value +seconds 1s +minutes 1m, 3m, 5m, 15m, 30m +hours 1h, 2h, 4h, 6h, 8h, 12h +days 1d, 3d +weeks 1w +months 1M +Notes: + +If startTime and endTime are not sent, the most recent klines are returned. +Supported values for timeZone: +Hours and minutes (e.g. -1:00, 05:45) +Only hours (e.g. 0, 8, 4) +Accepted range is strictly [-12:00 to +14:00] inclusive +If timeZone provided, kline intervals are interpreted in that timezone instead of UTC. +Note that startTime and endTime are always interpreted in UTC, regardless of timeZone. +Data Source: Database + +Response: + +[ + [ + 1499040000000, // Kline open time + "0.01634790", // Open price + "0.80000000", // High price + "0.01575800", // Low price + "0.01577100", // Close price + "148976.11427815", // Volume + 1499644799999, // Kline Close time + "2434.19055334", // Quote asset volume + 308, // Number of trades + "1756.87402397", // Taker buy base asset volume + "28.46694368", // Taker buy quote asset volume + "0" // Unused field, ignore. + ] +] + + +## WebSocket Streams for Binance + +## General WSS information + +- The base endpoint is: **wss://stream.binance.com:9443** or **wss://stream.binance.com:443**. +- Streams can be accessed either in a single raw stream or in a combined stream. +- Raw streams are accessed at **/ws/** +- Combined streams are accessed at **/stream?streams=//** +- Combined stream events are wrapped as follows: **{"stream":"","data":}** +- All symbols for streams are **lowercase** +- A single connection to **stream.binance.com** is only valid for 24 hours; expect to be disconnected at the 24 hour mark +- The WebSocket server will send a `ping frame` every 20 seconds. + - If the WebSocket server does not receive a `pong frame` back from the connection within a minute the connection will be disconnected. + - When you receive a ping, you must send a pong with a copy of ping's payload as soon as possible. + - Unsolicited `pong frames` are allowed, but will not prevent disconnection. **It is recommended that the payload for these pong frames are empty.** +- The base endpoint **wss://data-stream.binance.vision** can be subscribed to receive **only** market data messages. + User data stream is **NOT** available from this URL. +- All time and timestamp related fields are **milliseconds by default**. To receive the information in microseconds, please add the parameter `timeUnit=MICROSECOND or timeUnit=microsecond` in the URL. + - For example: `/stream?streams=btcusdt@trade&timeUnit=MICROSECOND` +- If your request contains a symbol name containing non-ASCII characters, then the stream events may contain non-ASCII characters encoded in UTF-8. +- \[All Market Mini Tickers Stream\](#all-market-mini-tickers-stream and [All Market Rolling Window Statistics Streams](https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams#all-market-rolling-window-statistics-streams) events may contain non-ASCII characters encoded in UTF-8. + +## WebSocket Limits + +- WebSocket connections have a limit of 5 incoming messages per second. A message is considered: + - A PING frame + - A PONG frame + - A JSON controlled message (e.g. subscribe, unsubscribe) +- A connection that goes beyond the limit will be disconnected; IPs that are repeatedly disconnected may be banned. +- A single connection can listen to a maximum of 1024 streams. +- There is a limit of **300 connections per attempt every 5 minutes per IP**. + +## Live Subscribing/Unsubscribing to streams + +- The following data can be sent through the WebSocket instance in order to subscribe/unsubscribe from streams. Examples can be seen below. +- The `id` is used as an identifier to uniquely identify the messages going back and forth. The following formats are accepted: + - 64-bit signed integer + - alphanumeric strings; max length 36 + - `null` +- In the response, if the `result` received is `null` this means the request sent was a success for non-query requests (e.g. Subscribing/Unsubscribing). + +### Subscribe to a stream + +- Request +- Response + ```javascript + { + "result": null, + "id": 1 + } + ``` + +### Unsubscribe to a stream + +- Request +- Response + ```javascript + { + "result": null, + "id": 312 + } + ``` + +### Listing Subscriptions + +- Request + ```javascript + { + "method": "LIST_SUBSCRIPTIONS", + "id": 3 + } + ``` +- Response + ```javascript + { + "result": ["btcusdt@aggTrade"], + "id": 3 + } + ``` + +### Setting Properties + +Currently, the only property that can be set is whether `combined` stream payloads are enabled or not. The combined property is set to `false` when connecting using `/ws/` ("raw streams") and `true` when connecting using `/stream/`. + +- Request + ```javascript + { + "method": "SET_PROPERTY", + "params": ["combined", true], + "id": 5 + } + ``` +- Response + ```javascript + { + "result": null, + "id": 5 + } + ``` + +### Retrieving Properties + +- Request + ```javascript + { + "method": "GET_PROPERTY", + "params": ["combined"], + "id": 2 + } + ``` +- Response + ```javascript + { + "result": true, // Indicates that combined is set to true. + "id": 2 + } + ``` + +| Error Message | Description | +| --- | --- | +| {"code": 0, "msg": "Unknown property","id": %s} | Parameter used in the `SET_PROPERTY` or `GET_PROPERTY` was invalid | +| {"code": 1, "msg": "Invalid value type: expected Boolean"} | Value should only be `true` or `false` | +| {"code": 2, "msg": "Invalid request: property name must be a string"} | Property name provided was invalid | +| {"code": 2, "msg": "Invalid request: request ID must be an unsigned integer"} | Parameter `id` had to be provided or the value provided in the `id` parameter is an unsupported type | +| {"code": 2, "msg": "Invalid request: unknown variant %s, expected one of `SUBSCRIBE`, `UNSUBSCRIBE`, `LIST_SUBSCRIPTIONS`, `SET_PROPERTY`, `GET_PROPERTY` at line 1 column 28"} | Possible typo in the provided method or provided method was neither of the expected values | +| {"code": 2, "msg": "Invalid request: too many parameters"} | Unnecessary parameters provided in the data | +| {"code": 2, "msg": "Invalid request: property name must be a string"} | Property name was not provided | +| {"code": 2, "msg": "Invalid request: missing field `method` at line 1 column 73"} | `method` was not provided in the data | +| {"code":3,"msg":"Invalid JSON: expected value at line %s column %s"} | JSON data sent has incorrect syntax. | + +## Detailed Stream information + +## Aggregate Trade Streams + +The Aggregate Trade Streams push trade information that is aggregated for a single taker order. + +**Stream Name:** @aggTrade + +**Update Speed:** Real-time + +**Payload:** + +```javascript +{ + "e": "aggTrade", // Event type + "E": 1672515782136, // Event time + "s": "BNBBTC", // Symbol + "a": 12345, // Aggregate trade ID + "p": "0.001", // Price + "q": "100", // Quantity + "f": 100, // First trade ID + "l": 105, // Last trade ID + "T": 1672515782136, // Trade time + "m": true, // Is the buyer the market maker? + "M": true // Ignore +} +``` + +## Trade Streams + +The Trade Streams push raw trade information; each trade has a unique buyer and seller. + +**Stream Name:** @trade + +**Update Speed:** Real-time + +**Payload:** + +```javascript +{ + "e": "trade", // Event type + "E": 1672515782136, // Event time + "s": "BNBBTC", // Symbol + "t": 12345, // Trade ID + "p": "0.001", // Price + "q": "100", // Quantity + "T": 1672515782136, // Trade time + "m": true, // Is the buyer the market maker? + "M": true // Ignore +} +``` + +## Kline/Candlestick Streams for UTC + +The Kline/Candlestick Stream push updates to the current klines/candlestick every second in `UTC+0` timezone + +**Kline/Candlestick chart intervals:** + +s-> seconds; m -> minutes; h -> hours; d -> days; w -> weeks; M -> months + +- 1s +- 1m +- 3m +- 5m +- 15m +- 30m +- 1h +- 2h +- 4h +- 6h +- 8h +- 12h +- 1d +- 3d +- 1w +- 1M + +**Stream Name:** @kline\_ + +**Update Speed:** 1000ms for `1s`, 2000ms for the other intervals + +**Payload:** + +```javascript +{ + "e": "kline", // Event type + "E": 1672515782136, // Event time + "s": "BNBBTC", // Symbol + "k": { + "t": 1672515780000, // Kline start time + "T": 1672515839999, // Kline close time + "s": "BNBBTC", // Symbol + "i": "1m", // Interval + "f": 100, // First trade ID + "L": 200, // Last trade ID + "o": "0.0010", // Open price + "c": "0.0020", // Close price + "h": "0.0025", // High price + "l": "0.0015", // Low price + "v": "1000", // Base asset volume + "n": 100, // Number of trades + "x": false, // Is this kline closed? + "q": "1.0000", // Quote asset volume + "V": "500", // Taker buy base asset volume + "Q": "0.500", // Taker buy quote asset volume + "B": "123456" // Ignore + } +} +``` + +## Kline/Candlestick Streams with timezone offset + +The Kline/Candlestick Stream push updates to the current klines/candlestick every second in `UTC+8` timezone + +**Kline/Candlestick chart intervals:** + +Supported intervals: See [`Kline/Candlestick chart intervals`](https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams#kline-intervals) + +**UTC+8 timezone offset:** + +- Kline intervals open and close in the `UTC+8` timezone. For example the `1d` klines will open at the beginning of the `UTC+8` day, and close at the end of the `UTC+8` day. +- Note that `E` (event time), `t` (start time) and `T` (close time) in the payload are Unix timestamps, which are always interpreted in UTC. + +**Stream Name:** @kline\_@+08:00 + +**Update Speed:** 1000ms for `1s`, 2000ms for the other intervals + +**Payload:** + +```javascript +{ + "e": "kline", // Event type + "E": 1672515782136, // Event time + "s": "BNBBTC", // Symbol + "k": { + "t": 1672515780000, // Kline start time + "T": 1672515839999, // Kline close time + "s": "BNBBTC", // Symbol + "i": "1m", // Interval + "f": 100, // First trade ID + "L": 200, // Last trade ID + "o": "0.0010", // Open price + "c": "0.0020", // Close price + "h": "0.0025", // High price + "l": "0.0015", // Low price + "v": "1000", // Base asset volume + "n": 100, // Number of trades + "x": false, // Is this kline closed? + "q": "1.0000", // Quote asset volume + "V": "500", // Taker buy base asset volume + "Q": "0.500", // Taker buy quote asset volume + "B": "123456" // Ignore + } +} +``` \ No newline at end of file diff --git a/docs/swingtrading/indicator.md b/docs/swingtrading/indicator.md new file mode 100644 index 0000000..bea698b --- /dev/null +++ b/docs/swingtrading/indicator.md @@ -0,0 +1,287 @@ +# Trading Signals + +![Language Details](https://img.shields.io/github/languages/top/bennycode/trading-signals) ![Code Coverage](https://img.shields.io/codecov/c/github/bennycode/trading-signals/main) ![License](https://img.shields.io/npm/l/trading-signals.svg) ![Package Version](https://img.shields.io/npm/v/trading-signals.svg) + +Technical indicators and overlays to run [technical analysis](https://en.wikipedia.org/wiki/Technical_analysis) with JavaScript / TypeScript. + +## Motivation + +The "trading-signals" library provides a TypeScript implementation for common technical indicators. It is well-suited for algorithmic trading, allowing developers to perform signal computations for automated trading strategies. + +All indicators can be updated over time by streaming data (prices or [candles](https://en.wikipedia.org/wiki/Candlestick_chart)) to the `add` method. Some indicators also provide `static` batch methods for further performance improvements when providing data up-front during a backtest or historical data import. You can try it out streaming input data by running the provided [demo script](./src/start/demo.ts) with `npm start`, which uses a keyboard input stream. + +## Features + +- **Streaming Updates:** No need to reprocess historical data +- **Replace Mode:** Efficient live chart updates +- **Lazy Evaluation:** Indicators only calculate when stable +- **Memory Efficiency:** Rolling windows, not full history storage +- **Excellent Test Coverage:** 100% across all metrics +- **Zero Runtime Dependencies:** Minimal bundle size +- **Type Safety:** Full TypeScript with strict mode + +## Installation + +```bash +npm install trading-signals +``` + +## Usage + +**CommonJS:** + +```ts +const {SMA} = require('trading-signals'); +``` + +**ESM:** + +```ts +import {SMA} from 'trading-signals'; +``` + +**Example:** + +```typescript +import {SMA} from 'trading-signals'; + +const sma = new SMA(3); + +// You can add values individually: +sma.add(40); +sma.add(30); +sma.add(20); + +// You can add multiple values at once: +sma.updates([20, 40, 80]); + +// You can replace a previous value (useful for live charting): +sma.replace(40); + +// You can check if an indicator is stable: +console.log(sma.isStable); // true + +// If an indicator is stable, you can get its result: +console.log(sma.getResult()); // 50.0003 + +// You can also get the result without optional chaining: +console.log(sma.getResultOrThrow()); // 50.0003 + +// Various precisions are available too: +console.log(sma.getResultOrThrow().toFixed(2)); // "50.00" +console.log(sma.getResultOrThrow().toFixed(4)); // "50.0003" + +// Each indicator also includes convenient features such as "lowest" and "highest" lifetime values: +console.log(sma.lowest?.toFixed(2)); // "23.33" +console.log(sma.highest?.toFixed(2)); // "53.33" +``` + +### When to use `add(...)`? + +To input data, you need to call the indicator's `add` method. Depending on whether the minimum required input data for the interval has been reached, the `add` method may or may not return a result from the indicator. + +### When to use `getResultOrThrow()`? + +You can call `getResultOrThrow()` at any point in time, but it throws errors unless an indicator has received the minimum amount of data. If you call `getResultOrThrow()` before an indicator has received the required amount of input values, a `NotEnoughDataError` will be thrown. + +**Example:** + +```ts +import {SMA} from 'trading-signals'; + +// Our interval is 3, so we need 3 input values +const sma = new SMA(3); + +// We supply 2 input values +sma.add(10); +sma.add(40); + +try { + // We will get an error, because the minimum amount of inputs is 3 + sma.getResultOrThrow(); +} catch (error) { + console.log(error.constructor.name); // "NotEnoughDataError" +} + +// We will supply the 3rd input value +sma.add(70); + +// Now, we will receive a proper result +console.log(sma.getResultOrThrow()); // 40 +``` + +Most of the time, the minimum amount of data depends on the interval / time period used. If you're not sure, take a look at the test files for the indicator to see examples of correct usage. + +### When to use `getRequiredInputs()`? + +Every indicator provides a `getRequiredInputs()` method that returns the minimum number of input values needed before the indicator becomes stable and can produce results. This is useful for validation and understanding when an indicator will start producing return values. + +**Example:** + +```ts +import {SMA, EMA, RSI} from 'trading-signals'; + +const sma = new SMA(5); +console.log(sma.getRequiredInputs()); // 5 +``` + +The required inputs often depend on the indicator's configuration (like interval/period) and its internal calculation requirements. Some indicators like **MACD** or **Stochastic Oscillator** may require more inputs than their primary period because they use multiple moving averages or lookback periods internally. + +### When to use `getSignal()`? + +Many momentum and trend indicators provide a `getSignal()` method that returns the current trading signal state along with change detection. This is useful for identifying potential trading opportunities. + +**Example:** + +```ts +import {RSI} from 'trading-signals'; + +const rsi = new RSI(14); + +// Add price data +// ... + +// Get the trading signal +const signal = rsi.getSignal(); +console.log(signal.state); // "BEARISH", "BULLISH", "SIDEWAYS", or "UNKNOWN" +console.log(signal.hasChanged); // true if the signal state changed from the previous value +``` + +## Technical Indicator Types + +### Indicator Function + +- Momentum indicators: Measure the speed and strength (intensity) of price movements in a particular direction (overbought/oversold) +- Trend indicators: Measure the direction of a trend (bullish/bearish) +- Volatility indicators: Measure the degree of variation in prices over time, regardless of direction +- Volume indicators: Measure the strength of a trend based on volume + +**Key readings:** + +- Bullish sentiment: expect prices to rise +- Bearish sentiment: expect prices to fall +- Overbought condition: price may have risen too much too fast, meaning it’s trending up, but traders expect a short-term dip before continuing higher +- Oversold condition: price may have dropped too much too fast, meaning it’s trending down, but traders expect a short-term bounce before continuing lower or reversing upward + +### Indicator Timing + +- Leading Indicators: Predictive tools that try to signal future price movements before they happen (i.e. RSI, Stochastic Oscillator, Volume spikes) +- Lagging Indicators: Confirmative tools that signal after a trend or move has already started (i.e. Moving Averages, MACD, ADX) + +### Indicator Scale + +- Indicators: Have no upper or lower limits +- Oscillators: Move within a fixed range (e.g. 0-100, –1 to +1) + +## Supported Technical Indicators + +1. Acceleration Bands (ABANDS) +1. Accelerator Oscillator (AC) +1. Average Directional Index (ADX) +1. Average True Range (ATR) +1. Awesome Oscillator (AO) +1. Bollinger Bands (BBANDS) +1. Bollinger Bands Width (BBW) +1. Center of Gravity (CG) +1. Commodity Channel Index (CCI) +1. Directional Movement Index (DMI / DX) +1. Double Exponential Moving Average (DEMA) +1. Dual Moving Average (DMA) +1. Exponential Moving Average (EMA) +1. Interquartile Range (IQR) +1. Linear Regression (LINREG) +1. Mean Absolute Deviation (MAD) +1. Momentum (MOM / MTM) +1. Moving Average Convergence Divergence (MACD) +1. On-Balance Volume (OBV) +1. Parabolic SAR (PSAR) +1. Range Expansion Index (REI) +1. Rate-of-Change (ROC) +1. Relative Moving Average (RMA) +1. Relative Strength Index (RSI) +1. Simple Moving Average (SMA) +1. Spencer's 15-Point Moving Average (SMA15) +1. Stochastic Oscillator (STOCH) +1. Stochastic RSI (STOCHRSI) +1. Tom Demark's Sequential Indicator (TDS) +1. True Range (TR) +1. Volume-Weighted Average Price (VWAP) +1. Weighted Moving Average (WMA) +1. Wilder's Smoothed Moving Average (WSMA / WWS / SMMA / MEMA) +1. Williams %R (WILLR) +1. Zig Zag Indicator (ZigZag) + +Utility Methods: + +1. Average / Mean +1. Grid Sizing (for [grid trading bots](https://b2broker.com/news/understanding-grid-trading-purpose-pros-cons/)) +1. Maximum +1. Median +1. Minimum +1. Quartile +1. Standard Deviation +1. Streaks +1. Weekday + +## Performance + +### Floating-point arithmetic caveats + +JavaScript uses double-precision floating-point arithmetic. For example, `0.1 + 0.2` yields `0.30000000000000004` due to binary floating-point representation. + +![JavaScript arithmetic](https://raw.githubusercontent.com/bennycode/trading-signals/main/packages/trading-signals/js-arithmetic.png) + +While this isn’t perfectly accurate, it usually doesn’t matter in practice since indicators often work with averages, which already smooth out precision. In test cases, you can control precision by using Vitest’s [toBeCloseTo](https://vitest.dev/api/expect.html#tobecloseto) assertion. + +Earlier versions of this library (up to version 6) used [big.js][1] for arbitrary-precision arithmetic, but that made calculations about 100x slower on average. For this reason, support for [big.js][1] was removed starting with version 7. + +## Disclaimer + +The information and publications of [trading-signals](https://github.com/bennycode/trading-signals) do not constitute financial advice, investment advice, trading advice or any other form of advice. All results from [trading-signals](https://github.com/bennycode/trading-signals) are intended for information purposes only. + +It is very important to do your own analysis before making any investment based on your own personal circumstances. If you need financial advice or further advice in general, it is recommended that you identify a relevantly qualified individual in your jurisdiction who can advise you accordingly. + +## Alternatives + +- [Cloud9Trader Indicators (JavaScript)](https://github.com/Cloud9Trader/TechnicalIndicators) +- [Crypto Trading Hub Indicators (TypeScript)](https://github.com/anandanand84/technicalindicators) +- [Highcharts Indicators (TypeScript)](https://github.com/highcharts/highcharts/tree/v12.3.0/ts/Stock/Indicators) +- [Indicator TS (TypeScript)](https://github.com/cinar/indicatorts) +- [Jesse Trading Bot Indicators (Python)](https://docs.jesse.trade/docs/indicators/reference.html) +- [LEAN Indicators (C#)](https://github.com/QuantConnect/Lean/tree/master/Indicators) +- [libindicators (C#)](https://github.com/mgfx/libindicators) +- [Pandas TA (Python)](https://github.com/twopirllc/pandas-ta) +- [Stock Indicators for .NET (C#)](https://github.com/DaveSkender/Stock.Indicators) +- [StockSharp (C#)](https://github.com/StockSharp/StockSharp) +- [ta-lib (C)](https://github.com/TA-Lib/ta-lib/tree/main/src/ta_func) +- [ta-math (TypeScript)](https://github.com/munrocket/ta-math) +- [ta4j (Java)](https://github.com/ta4j/ta4j) +- [Technical Analysis for Rust (Rust)](https://github.com/greyblake/ta-rs) +- [Technical Analysis Library using Pandas and Numpy (Python)](https://github.com/bukosabino/ta) +- [Tulip Indicators (ANSI C)](https://github.com/TulipCharts/tulipindicators) + +## Documentation + +Build and run the documentation: + +```bash +npm run docs +``` + +## Maintainers + +[![Benny Neugebauer on Stack Exchange][stack_exchange_bennycode_badge]][stack_exchange_bennycode_url] + +## ⭐️ Become a TypeScript rockstar! ⭐️ + +This package was built by Benny Neugebauer. Checkout my [**TypeScript course**](https://typescript.tv/) to become a coding rockstar! + +[](https://typescript.tv/) + +[1]: http://mikemcl.github.io/big.js/ +[stack_exchange_bennycode_badge]: https://stackexchange.com/users/flair/203782.png?theme=default +[stack_exchange_bennycode_url]: https://stackexchange.com/users/203782/benny-neugebauer?tab=accounts + +## License + +This project is [MIT](./LICENSE) licensed. \ No newline at end of file diff --git a/docs/swingtrading/runbook.md b/docs/swingtrading/runbook.md new file mode 100644 index 0000000..eefc4b5 --- /dev/null +++ b/docs/swingtrading/runbook.md @@ -0,0 +1,43 @@ +# Swing strategy (RSI on Binance ETHBTC) + +This repo’s `swing` strategy **trades the exchange symbol you run the bot with** (e.g. `TRADE_SYMBOL`), but **uses Binance spot `ETHBTC` 4h RSI(14)** as the global signal source (per `docs/swingtrading/strategy.md`). + +## Key behavior + +- **Signal source**: Binance spot `ETHBTC`, `4h` klines, RSI period `14`. +- **Trade target**: your selected exchange’s `TRADE_SYMBOL` / `*_SYMBOL` (same as other strategies). +- **Default mode**: short-only (configurable to long / short / both). +- **Stop-loss**: + - Tries to place a stop-loss order when supported by the venue. + - Always runs a real-time “kill-switch”: if price crosses the stop threshold, it market-closes. +- **Spot accounts**: if the connected account is `marketType=spot` and you configure `SWING_DIRECTION=short` (or `both`), the strategy will refuse to trade. + +## Environment variables + +- **Core** + - `SWING_DIRECTION`: `short` (default) | `long` | `both` + - `SWING_TRADE_AMOUNT`: position size (falls back to `TRADE_AMOUNT`) + - `SWING_POLL_INTERVAL_MS`: loop interval (default `500`) +- **RSI / signal** + - `SWING_RSI_PERIOD`: default `14` + - `SWING_RSI_HIGH`: default `70` + - `SWING_RSI_LOW`: default `30` + - `SWING_SIGNAL_SYMBOL`: default `ETHBTC` + - `SWING_SIGNAL_INTERVAL`: default `4h` +- **Risk / precision** + - `SWING_STOP_LOSS_PCT`: default `0.05` (5%) + - `SWING_MAX_CLOSE_SLIPPAGE_PCT`: default `0.05` + - `SWING_PRICE_TICK`, `SWING_QTY_STEP`: optional overrides (otherwise synced from exchange when supported) + +## Run + +```bash +bun run index.ts --strategy swing +``` + +Silent mode: + +```bash +bun run index.ts --strategy swing --silent +``` + diff --git a/docs/swingtrading/strategy.md b/docs/swingtrading/strategy.md new file mode 100644 index 0000000..1183d57 --- /dev/null +++ b/docs/swingtrading/strategy.md @@ -0,0 +1,24 @@ +无论在任何交易所或者平台运行,都遵循以下逻辑: + +无论交易任何交易对,都参照 ETHBTC 数据进行交易 + +从binance现货接口获取 ETHBTC 交易对 4 小时k线数据,rest 接口获取历史数据,获取500条即可 + +订阅ws获取最新一根K线的数据 + +参考 trading-signals 库,计算 RSI 指标,RSI 参数为 14 + +我们的策略仅单边做空,但需要预留配置,可以支持单边做多,单边做空,双向交易 + +下面针对单边做空的情况进行说明 + +RSI 指标上穿 70 时,进入等待做空状态,后续当 RSI 指标下穿 70 时,执行做空开仓操作 + +如果对应的平台支持挂止损 stop loss,则在开仓的同时需要挂一个开仓成本价上涨 5% 的止损单,止损百分比可以设置参数调整,同时也需要有实时监控的止损逻辑,如果在持仓状态下,当前交易对比开仓价格上涨超过 5% ,则执行主动的市价止损操作 + +策略需要实时监控当前的交易对ticker数据、orderbook数据,open orders、position,支持ws的全都使用ws数据实时更新 + +在有仓位的情况下,如果发现 ETHBTC 的 RSI 指标下穿 30,则进入等待平空状态,如果后续 RSI 指标上穿 30,并且确认仓位有盈利,则执行平空操作 + +如此循环往复 + diff --git a/package.json b/package.json index 14e1c83..fb30c5e 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "ethereum-cryptography": "^2.1.3", "ink": "^6.3.1", "react": "^19.1.1", + "trading-signals": "^7.4.3", "viem": "^2.43.1", "ws": "^8.18.3" } diff --git a/src/cli/args.ts b/src/cli/args.ts index e3ff87c..5aac2f6 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,4 +1,4 @@ -export type StrategyId = "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid"; +export type StrategyId = "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid"; export interface CliOptions { strategy?: StrategyId; @@ -9,6 +9,7 @@ export interface CliOptions { const STRATEGY_VALUES = new Set([ "trend", + "swing", "guardian", "maker", "maker-points", @@ -98,7 +99,7 @@ function assignExchange(options: CliOptions, raw: string): void { export function printCliHelp(): void { // eslint-disable-next-line no-console - console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + + console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + `Options:\n` + ` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` + ` Aliases: offset, offset-maker for the offset maker engine.\n` + diff --git a/src/cli/strategy-runner.ts b/src/cli/strategy-runner.ts index e4db2ac..e950ba2 100644 --- a/src/cli/strategy-runner.ts +++ b/src/cli/strategy-runner.ts @@ -1,4 +1,4 @@ -import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, tradingConfig } from "../config"; +import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, swingConfig, tradingConfig } from "../config"; import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; import type { ExchangeAdapter } from "../exchanges/adapter"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; @@ -7,6 +7,7 @@ import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/o import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine"; import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine"; import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine"; +import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine"; import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine"; import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine"; import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine"; @@ -21,6 +22,7 @@ type StrategyRunner = (options: RunnerOptions) => Promise; export const STRATEGY_LABELS: Record = { trend: "Trend Following", + swing: "Swing", guardian: "Guardian", maker: "Maker", "maker-points": "Maker Points", @@ -52,6 +54,19 @@ const STRATEGY_FACTORIES: Record = { offUpdate: (emitter) => engine.off("update", emitter), }); }, + swing: async (opts) => { + const config = swingConfig; + const adapter = createAdapterOrThrow(config.symbol); + const engine = new SwingEngine(config, adapter); + await runEngine({ + engine, + strategy: "swing", + silent: opts.silent, + getSnapshot: () => engine.getSnapshot(), + onUpdate: (emitter) => engine.on("update", emitter), + offUpdate: (emitter) => engine.off("update", emitter), + }); + }, guardian: async (opts) => { const config = tradingConfig; const adapter = createAdapterOrThrow(config.symbol); @@ -167,6 +182,7 @@ interface EngineHarness { async function runEngine< TSnapshot extends | TrendEngineSnapshot + | SwingEngineSnapshot | GuardianEngineSnapshot | MakerEngineSnapshot | MakerPointsSnapshot diff --git a/src/config.ts b/src/config.ts index a48f709..5b8a9b9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -403,6 +403,54 @@ export const liquidityMakerConfig: LiquidityMakerConfig = { entryDepthLevel: Math.max(1, Math.floor(parseNumber(process.env.MAKER_ENTRY_DEPTH_LEVEL, 1))), }; +export type SwingDirection = "both" | "long" | "short"; + +export interface SwingConfig { + symbol: string; + tradeAmount: number; + pollIntervalMs: number; + maxLogEntries: number; + maxCloseSlippagePct: number; + priceTick: number; + qtyStep: number; + direction: SwingDirection; + rsiPeriod: number; + rsiHigh: number; + rsiLow: number; + stopLossPct: number; + signalSymbol: string; + signalInterval: string; +} + +const resolveSwingDirection = (raw: string | undefined, fallback: SwingDirection): SwingDirection => { + if (!raw) return fallback; + const normalized = raw.trim().toLowerCase(); + if (normalized === "long" || normalized === "long-only") return "long"; + if (normalized === "short" || normalized === "short-only") return "short"; + if (normalized === "both" || normalized === "dual" || normalized === "bi" || normalized === "two-way") return "both"; + return fallback; +}; + +export const swingConfig: SwingConfig = { + symbol: resolveSymbolFromEnv(), + tradeAmount: parseNumber(process.env.SWING_TRADE_AMOUNT ?? process.env.TRADE_AMOUNT, 0.001), + pollIntervalMs: parseNumber(process.env.SWING_POLL_INTERVAL_MS, parseNumber(process.env.POLL_INTERVAL_MS, 500)), + maxLogEntries: parseNumber(process.env.SWING_MAX_LOG_ENTRIES, parseNumber(process.env.MAX_LOG_ENTRIES, 200)), + maxCloseSlippagePct: parseNumber( + process.env.SWING_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT, + 0.05 + ), + priceTick: parseNumber(process.env.SWING_PRICE_TICK ?? process.env.PRICE_TICK, 0.1), + qtyStep: parseNumber(process.env.SWING_QTY_STEP ?? process.env.QTY_STEP, 0.001), + direction: resolveSwingDirection(process.env.SWING_DIRECTION, "short"), + rsiPeriod: Math.max(1, Math.floor(parseNumber(process.env.SWING_RSI_PERIOD, 14))), + rsiHigh: parseNumber(process.env.SWING_RSI_HIGH, 70), + rsiLow: parseNumber(process.env.SWING_RSI_LOW, 30), + stopLossPct: Math.max(0, parseNumber(process.env.SWING_STOP_LOSS_PCT, 0.05)), + signalSymbol: (process.env.SWING_SIGNAL_SYMBOL ?? "ETHBTC").trim().toUpperCase(), + signalInterval: (process.env.SWING_SIGNAL_INTERVAL ?? "4h").trim(), +}; + export function isBasisStrategyEnabled(): boolean { const raw = process.env.ENABLE_BASIS_STRATEGY; if (!raw) return false; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 2855234..8bd7615 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -25,6 +25,11 @@ const translations: Record = { zh: "监控均线信号,自动进出场并维护止损/止盈", en: "Monitors SMA signals, automates entries/exits, maintains stops.", }, + "app.strategy.swing.label": { zh: "Swing 策略 (RSI14/4h)", en: "Swing (RSI14/4h)" }, + "app.strategy.swing.desc": { + zh: "使用 Binance ETHBTC 4h RSI 信号,主动开平仓并维护止损", + en: "Uses Binance ETHBTC 4h RSI signals to actively trade and maintain stops.", + }, "app.strategy.guardian.label": { zh: "Guardian 防守策略", en: "Guardian Protection" }, "app.strategy.guardian.desc": { zh: "不主动开仓,只为现有仓位补挂/移动止损,防止裸奔", @@ -132,6 +137,50 @@ const translations: Record = { "trend.label.long": { zh: "做多", en: "Long" }, "trend.label.short": { zh: "做空", en: "Short" }, "trend.label.none": { zh: "无信号", en: "No signal" }, + "swing.name": { zh: "Swing 策略", en: "swing strategy" }, + "swing.title": { zh: "Swing 策略仪表盘", en: "Swing Strategy Dashboard" }, + "swing.readyMessage": { zh: "正在等待交易所/RSI 信号…", en: "Waiting for exchange feeds / RSI signal..." }, + "swing.headerLine": { + zh: "交易所: {exchange} | 交易对: {symbol} | 方向: {direction} | 最近价格: {lastPrice} | 状态: {phase}", + en: "Exchange: {exchange} | Symbol: {symbol} | Mode: {direction} | Last: {lastPrice} | Phase: {phase}", + }, + "swing.signalLine": { + zh: "信号源: Binance {binanceSymbol} | 价格: {binancePrice} | RSI: {rsi} ({zone}) | 连接: {connection}", + en: "Signal: Binance {binanceSymbol} | Price: {binancePrice} | RSI: {rsi} ({zone}) | Conn: {connection}", + }, + "swing.statusLine": { + zh: "状态: {status} | 按 Esc 返回策略选择", + en: "Status: {status} | Press Esc to return to menu.", + }, + "swing.zone.overbought": { zh: "超买", en: "Overbought" }, + "swing.zone.oversold": { zh: "超卖", en: "Oversold" }, + "swing.zone.neutral": { zh: "正常区间", en: "Neutral" }, + "swing.zone.unknown": { zh: "未知", en: "Unknown" }, + "swing.phase.disabled": { zh: "已禁用", en: "Disabled" }, + "swing.phase.initializing": { zh: "初始化/同步中", en: "Initializing" }, + "swing.phase.observing": { zh: "观察", en: "Observing" }, + "swing.phase.waitingOpenShort": { zh: "等待开空", en: "Waiting to open short" }, + "swing.phase.waitingCloseShort": { zh: "等待平空", en: "Waiting to close short" }, + "swing.phase.waitingOpenLong": { zh: "等待开多", en: "Waiting to open long" }, + "swing.phase.waitingCloseLong": { zh: "等待平多", en: "Waiting to close long" }, + "swing.positionLine": { + zh: "方向: {direction} | 数量: {qty} | 开仓价: {entry}", + en: "Direction: {direction} | Size: {qty} | Entry: {entry}", + }, + "swing.pnlLine": { + zh: "浮动盈亏: {pnl} USDT | 账户未实现盈亏: {unrealized} USDT", + en: "Floating PnL: {pnl} USDT | Account Unrealized: {unrealized} USDT", + }, + "swing.stopLine": { + zh: "止损目标价: {stop}", + en: "Stop target: {stop}", + }, + "swing.stateTitle": { zh: "策略状态", en: "Strategy State" }, + "swing.armedLine": { + zh: "Armed: SE={se} SX={sx} | LE={le} LX={lx}", + en: "Armed: SE={se} SX={sx} | LE={le} LX={lx}", + }, + "swing.volumeLine": { zh: "累计成交量: {volume} USDT", en: "Total volume: {volume} USDT" }, "guardian.name": { zh: "Guardian 策略", en: "Guardian strategy" }, "guardian.title": { zh: "Guardian 策略仪表盘", en: "Guardian Strategy Dashboard" }, "guardian.readyMessage": { zh: "正在等待行情/账户推送…", en: "Waiting for market/account feeds..." }, diff --git a/src/strategy/common/binance-rsi.ts b/src/strategy/common/binance-rsi.ts new file mode 100644 index 0000000..198b1e6 --- /dev/null +++ b/src/strategy/common/binance-rsi.ts @@ -0,0 +1,428 @@ +import NodeWebSocket from "ws"; +import { RSI } from "trading-signals"; + +const WebSocketCtor: typeof globalThis.WebSocket = + typeof globalThis.WebSocket !== "undefined" + ? globalThis.WebSocket + : ((NodeWebSocket as unknown) as typeof globalThis.WebSocket); + +const DEFAULT_REST_BASE_URL = "https://api.binance.com"; +const DEFAULT_WS_BASE_URL = "wss://stream.binance.com:9443/ws"; + +// Binance sends frequent kline updates (typically 2s). We treat longer silence as stale. +const DATA_STALE_THRESHOLD_MS = 10_000; +const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000; +const HEARTBEAT_CHECK_INTERVAL_MS = 30_000; +const MAX_CONNECTION_DURATION_MS = 23 * 60 * 60 * 1000; + +const RECONNECT_DELAY_BASE_MS = 2000; +const RECONNECT_DELAY_MAX_MS = 60_000; + +export type BinanceConnectionState = "connected" | "disconnected" | "stale"; + +export interface BinanceRsiSnapshot { + symbol: string; + interval: string; + rsiPeriod: number; + rsi: number | null; + isStable: boolean; + lastClose: number | null; + candleOpenTime: number | null; + candleClosed: boolean | null; + updatedAt: number | null; + connectionState: BinanceConnectionState; +} + +type BinanceRsiListener = (snapshot: BinanceRsiSnapshot) => void; + +export class BinanceRsiTracker { + private ws: WebSocket | null = null; + private stopped = false; + private reconnectTimer: ReturnType | null = null; + private reconnectDelayMs = RECONNECT_DELAY_BASE_MS; + private heartbeatTimer: ReturnType | null = null; + private maxDurationTimer: ReturnType | null = null; + private lastMessageTime = 0; + private connectionState: BinanceConnectionState = "disconnected"; + + private rsi: RSI; + private candleOpenTime: number | null = null; + private candleClosed: boolean | null = null; + private lastClose: number | null = null; + private updatedAt: number | null = null; + + private listeners = new Set(); + + constructor( + private readonly symbol: string, + private readonly interval: string, + private readonly rsiPeriod: number, + private readonly options?: { + restBaseUrl?: string; + wsBaseUrl?: string; + limit?: number; + logger?: (context: string, error: unknown) => void; + } + ) { + this.rsi = new RSI(this.rsiPeriod); + } + + start(): void { + this.stopped = false; + void this.seedAndConnect("startup"); + } + + stop(): void { + this.stopped = true; + this.cleanup(); + } + + onUpdate(handler: BinanceRsiListener): void { + this.listeners.add(handler); + } + + offUpdate(handler: BinanceRsiListener): void { + this.listeners.delete(handler); + } + + getSnapshot(): BinanceRsiSnapshot { + return this.buildSnapshot(); + } + + private buildSnapshot(): BinanceRsiSnapshot { + const rsiValue = this.rsi.getResult(); + const rsi = typeof rsiValue === "number" && Number.isFinite(rsiValue) ? rsiValue : null; + return { + symbol: this.symbol, + interval: this.interval, + rsiPeriod: this.rsiPeriod, + rsi, + isStable: this.rsi.isStable === true, + lastClose: this.lastClose, + candleOpenTime: this.candleOpenTime, + candleClosed: this.candleClosed, + updatedAt: this.updatedAt, + connectionState: this.connectionState, + }; + } + + private emitUpdate(): void { + const snapshot = this.buildSnapshot(); + for (const listener of this.listeners) { + try { + listener(snapshot); + } catch (error) { + this.options?.logger?.("binanceRsi listener", error); + } + } + } + + private cleanup(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + if (this.maxDurationTimer) { + clearTimeout(this.maxDurationTimer); + this.maxDurationTimer = null; + } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + try { + this.ws.close(); + } catch { + // ignore + } + this.ws = null; + } + this.updateConnectionState("disconnected"); + } + + private updateConnectionState(next: BinanceConnectionState): void { + if (this.connectionState === next) return; + this.connectionState = next; + this.emitUpdate(); + } + + private scheduleReconnect(reason: string): void { + if (this.reconnectTimer || this.stopped) return; + this.options?.logger?.("binanceRsi", `Scheduling reconnect: ${reason}`); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, RECONNECT_DELAY_MAX_MS); + void this.seedAndConnect(`reconnect:${reason}`); + }, this.reconnectDelayMs); + } + + private forceReconnect(reason: string): void { + if (this.stopped) return; + this.options?.logger?.("binanceRsi", `Force reconnect: ${reason}`); + // Stop timers and close WS; keep RSI state (we will reseed on reconnect). + this.stopHeartbeatMonitor(); + this.stopMaxDurationTimer(); + if (this.ws) { + try { + this.ws.close(); + } catch { + // ignore + } + this.ws = null; + } + this.updateConnectionState("disconnected"); + this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS; + this.scheduleReconnect(reason); + } + + private startHeartbeatMonitor(): void { + if (this.heartbeatTimer) return; + this.heartbeatTimer = setInterval(() => { + const elapsed = Date.now() - this.lastMessageTime; + if (elapsed > DATA_STALE_THRESHOLD_MS && this.connectionState === "connected") { + this.updateConnectionState("stale"); + } + if (elapsed > HEARTBEAT_TIMEOUT_MS) { + this.forceReconnect(`heartbeat_timeout:${elapsed}`); + } + }, HEARTBEAT_CHECK_INTERVAL_MS); + } + + private stopHeartbeatMonitor(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + private startMaxDurationTimer(): void { + if (this.maxDurationTimer) return; + this.maxDurationTimer = setTimeout(() => { + this.forceReconnect("max_duration"); + }, MAX_CONNECTION_DURATION_MS); + } + + private stopMaxDurationTimer(): void { + if (this.maxDurationTimer) { + clearTimeout(this.maxDurationTimer); + this.maxDurationTimer = null; + } + } + + private buildRestUrl(): string { + const base = this.options?.restBaseUrl ?? DEFAULT_REST_BASE_URL; + return base; + } + + private buildWsUrl(): string { + const base = this.options?.wsBaseUrl ?? DEFAULT_WS_BASE_URL; + const stream = `${this.symbol.toLowerCase()}@kline_${this.interval}`; + return `${base}/${stream}`; + } + + private async seedAndConnect(reason: string): Promise { + if (this.stopped) return; + try { + await this.seedFromRest(); + } catch (error) { + this.options?.logger?.(`binanceRsi seed (${reason})`, error); + this.scheduleReconnect(`seed_failed:${reason}`); + return; + } + this.connectWs(reason); + } + + private async seedFromRest(): Promise { + const limit = Math.max(10, Math.floor(this.options?.limit ?? 500)); + const base = this.buildRestUrl(); + const url = new URL("/api/v3/klines", base); + url.searchParams.set("symbol", this.symbol.toUpperCase()); + url.searchParams.set("interval", this.interval); + url.searchParams.set("limit", String(limit)); + + const res = await fetch(url.toString(), { method: "GET" }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Binance klines HTTP ${res.status}: ${text.slice(0, 200)}`); + } + const data = (await res.json()) as unknown; + if (!Array.isArray(data)) { + throw new Error("Binance klines response is not an array"); + } + + // Reset RSI from scratch for determinism. + this.rsi = new RSI(this.rsiPeriod); + this.candleOpenTime = null; + this.candleClosed = null; + this.lastClose = null; + this.updatedAt = null; + + // Binance kline array format: + // [ openTime, open, high, low, close, volume, closeTime, ... ] + const rows = data + .map((row) => (Array.isArray(row) ? row : null)) + .filter((row): row is any[] => Array.isArray(row) && row.length >= 7) + .map((row) => ({ + openTime: Number(row[0]), + close: Number(row[4]), + closeTime: Number(row[6]), + })) + .filter((k) => Number.isFinite(k.openTime) && Number.isFinite(k.close) && Number.isFinite(k.closeTime)) + .sort((a, b) => a.openTime - b.openTime); + + for (const k of rows) { + this.rsi.add(k.close); + this.candleOpenTime = k.openTime; + this.candleClosed = true; + this.lastClose = k.close; + this.updatedAt = Date.now(); + } + + // Last bar may still be forming; we treat it as replaceable. + if (rows.length > 0) { + this.candleClosed = false; + } + + this.emitUpdate(); + } + + private connectWs(reason: string): void { + if (this.ws || this.stopped) return; + const url = this.buildWsUrl(); + this.ws = new WebSocketCtor(url); + + const handleOpen = () => { + this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS; + this.lastMessageTime = Date.now(); + this.updateConnectionState("connected"); + this.startHeartbeatMonitor(); + this.startMaxDurationTimer(); + this.options?.logger?.("binanceRsi", `WebSocket connected (${reason})`); + }; + + const handleClose = () => { + this.ws = null; + this.stopHeartbeatMonitor(); + this.stopMaxDurationTimer(); + if (!this.stopped) { + this.updateConnectionState("disconnected"); + this.scheduleReconnect("ws_close"); + } + }; + + const handleError = (error: unknown) => { + this.options?.logger?.("binanceRsi", error); + }; + + const handlePing = (data: unknown) => { + this.lastMessageTime = Date.now(); + if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") { + try { + this.ws.pong(data as any); + } catch (error) { + this.options?.logger?.("binanceRsi pong", error); + } + } + }; + + const handleMessage = (event: { data: unknown }) => { + this.lastMessageTime = Date.now(); + if (this.connectionState === "stale") { + this.updateConnectionState("connected"); + } + this.handlePayload(event.data); + }; + + if ("addEventListener" in this.ws && typeof this.ws.addEventListener === "function") { + this.ws.addEventListener("open", handleOpen); + this.ws.addEventListener("message", handleMessage as any); + this.ws.addEventListener("close", handleClose); + this.ws.addEventListener("error", handleError as any); + this.ws.addEventListener("ping", handlePing as any); + } else if ("on" in this.ws && typeof (this.ws as any).on === "function") { + const nodeSocket = this.ws as any; + nodeSocket.on("open", handleOpen); + nodeSocket.on("message", (data: unknown) => handleMessage({ data })); + nodeSocket.on("close", handleClose); + nodeSocket.on("error", handleError); + nodeSocket.on("ping", handlePing); + } else { + (this.ws as any).onopen = handleOpen; + (this.ws as any).onmessage = handleMessage; + (this.ws as any).onclose = handleClose; + (this.ws as any).onerror = handleError; + } + } + + private handlePayload(data: unknown): void { + const parsed = this.parsePayload(data); + if (!parsed) return; + const openTime = Number(parsed.openTime); + const close = Number(parsed.close); + const isClosed = Boolean(parsed.isClosed); + if (!Number.isFinite(openTime) || !Number.isFinite(close)) return; + + this.applyCandleUpdate({ openTime, close, isClosed }); + } + + private applyCandleUpdate(params: { openTime: number; close: number; isClosed: boolean }): void { + const { openTime, close, isClosed } = params; + + if (this.candleOpenTime == null) { + this.rsi.add(close); + this.candleOpenTime = openTime; + this.candleClosed = isClosed; + this.lastClose = close; + this.updatedAt = Date.now(); + this.emitUpdate(); + return; + } + + if (openTime < this.candleOpenTime) { + // Out-of-order update; ignore. + return; + } + + if (openTime === this.candleOpenTime) { + // Same candle: replace last close. + this.rsi.replace(close); + this.candleClosed = isClosed; + this.lastClose = close; + this.updatedAt = Date.now(); + this.emitUpdate(); + return; + } + + // New candle started. + this.rsi.add(close); + this.candleOpenTime = openTime; + this.candleClosed = isClosed; + this.lastClose = close; + this.updatedAt = Date.now(); + this.emitUpdate(); + } + + private parsePayload( + data: unknown + ): { openTime?: number; close?: number; isClosed?: boolean } | null { + try { + const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null; + if (!text) return null; + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object") return null; + // Raw stream payload shape: + // { e: "kline", s: "ETHBTC", k: { t: , c: , x: } } + const k = (parsed as any).k; + if (!k || typeof k !== "object") return null; + return { + openTime: Number(k.t), + close: Number(k.c), + isClosed: Boolean(k.x), + }; + } catch { + return null; + } + } +} + diff --git a/src/strategy/swing-engine.ts b/src/strategy/swing-engine.ts new file mode 100644 index 0000000..faa1371 --- /dev/null +++ b/src/strategy/swing-engine.ts @@ -0,0 +1,624 @@ +import type { ExchangeAdapter } from "../exchanges/adapter"; +import type { AsterAccountSnapshot, AsterDepth, AsterOrder, AsterTicker } from "../exchanges/types"; +import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; +import { marketClose, placeMarketOrder, placeStopLossOrder, unlockOperating } from "../core/order-coordinator"; +import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; +import { extractMessage, isRateLimitError, isUnknownOrderError } from "../utils/errors"; +import { getPosition, type PositionSnapshot } from "../utils/strategy"; +import { computePositionPnl } from "../utils/pnl"; +import { getMidOrLast, getTopPrices } from "../utils/price"; +import { RateLimitController } from "../core/lib/rate-limit"; +import { StrategyEventEmitter } from "./common/event-emitter"; +import { safeSubscribe, type LogHandler } from "./common/subscriptions"; +import { SessionVolumeTracker } from "./common/session-volume"; +import { t } from "../i18n"; +import { BinanceRsiTracker, type BinanceRsiSnapshot } from "./common/binance-rsi"; +import { createInitialSwingState, stepSwing, type SwingState } from "./swing-logic"; +import { isOrderActiveStatus } from "../utils/order-status"; +import type { SwingConfig } from "../config"; + +export type SwingRsiZone = "overbought" | "oversold" | "neutral" | "unknown"; +export type SwingPhase = + | "disabled" + | "initializing" + | "observing" + | "waiting_open_short" + | "waiting_open_long" + | "waiting_close_short" + | "waiting_close_long"; + +export interface SwingEngineSnapshot { + ready: boolean; + disabled: boolean; + symbol: string; + direction: SwingConfig["direction"]; + + lastPrice: number | null; + phase: SwingPhase; + binancePrice: number | null; + rsi: number | null; + rsiStable: boolean; + rsiZone: SwingRsiZone; + binanceConnection: BinanceRsiSnapshot["connectionState"]; + binanceUpdatedAt: number | null; + + armed: Pick< + SwingState, + "armedShortEntry" | "armedShortExit" | "armedLongEntry" | "armedLongExit" + >; + + position: PositionSnapshot; + pnl: number; + unrealized: number; + sessionVolume: number; + + stopLossTarget: number | null; + stopLossKillSwitch: boolean; + + openOrders: AsterOrder[]; + depth: AsterDepth | null; + ticker: AsterTicker | null; + + tradeLog: TradeLogEntry[]; + lastUpdated: number | null; + error: string | null; +} + +type SwingEvent = "update"; +type SwingListener = (snapshot: SwingEngineSnapshot) => void; + +const EPS = 1e-5; + +export class SwingEngine { + private accountSnapshot: AsterAccountSnapshot | null = null; + private openOrders: AsterOrder[] = []; + private depthSnapshot: AsterDepth | null = null; + private tickerSnapshot: AsterTicker | null = null; + + private readonly locks: OrderLockMap = {}; + private readonly timers: OrderTimerMap = {}; + private readonly pending: OrderPendingMap = {}; + + private readonly tradeLog: ReturnType; + private readonly events = new StrategyEventEmitter(); + private readonly sessionVolume = new SessionVolumeTracker(); + private readonly rateLimit: RateLimitController; + + private readonly binanceRsi: BinanceRsiTracker; + private binanceSnapshot: BinanceRsiSnapshot; + + private timer: ReturnType | null = null; + private processing = false; + private disabled = false; + private lastError: string | null = null; + + private ordersSnapshotReady = false; + private precisionSync: Promise | null = null; + private swingState: SwingState = createInitialSwingState(); + + // Stop-loss placement de-bounce + private lastStopAttempt: { side: "BUY" | "SELL" | null; price: number | null; at: number } = { + side: null, + price: null, + at: 0, + }; + + constructor(private readonly config: SwingConfig, private readonly exchange: ExchangeAdapter) { + this.tradeLog = createTradeLog(this.config.maxLogEntries); + this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) => + this.tradeLog.push(type, detail) + ); + + this.binanceRsi = new BinanceRsiTracker( + this.config.signalSymbol, + this.config.signalInterval, + this.config.rsiPeriod, + { + limit: 500, + logger: (context, error) => { + // Keep Binance errors visible but non-fatal. + this.tradeLog.push("warn", `[Binance] ${context}: ${String(error)}`); + }, + } + ); + this.binanceSnapshot = this.binanceRsi.getSnapshot(); + this.binanceRsi.onUpdate((snapshot) => { + this.binanceSnapshot = snapshot; + this.emitUpdate(); + }); + this.binanceRsi.start(); + + this.syncPrecision(); + this.bootstrap(); + } + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => { + void this.tick(); + }, this.config.pollIntervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + // Binance tracker is external IO; stop it too. + this.binanceRsi.stop(); + } + + on(event: SwingEvent, handler: SwingListener): void { + this.events.on(event, handler); + } + + off(event: SwingEvent, handler: SwingListener): void { + this.events.off(event, handler); + } + + getSnapshot(): SwingEngineSnapshot { + return this.buildSnapshot(); + } + + private bootstrap(): void { + const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail); + + safeSubscribe( + this.exchange.watchAccount.bind(this.exchange), + (snapshot) => { + this.accountSnapshot = snapshot; + const position = getPosition(snapshot, this.config.symbol); + const reference = this.getReferencePrice(); + this.sessionVolume.update(position, reference); + + // Safe-by-default: refuse short mode on spot accounts. + if ( + snapshot.marketType === "spot" && + (this.config.direction === "short" || this.config.direction === "both") + ) { + if (!this.disabled) { + this.disabled = true; + this.lastError = "Swing strategy requires perp/margin for shorting; spot accounts cannot short."; + this.tradeLog.push("error", this.lastError); + } + } + + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }), + processFail: (error) => t("log.process.accountError", { error: extractMessage(error) }), + } + ); + + safeSubscribe( + this.exchange.watchOrders.bind(this.exchange), + (orders) => { + this.synchronizeLocks(orders); + this.openOrders = Array.isArray(orders) + ? orders.filter( + (order) => + order.type !== "MARKET" && + order.symbol === this.config.symbol && + isOrderActiveStatus(order.status) + ) + : []; + this.ordersSnapshotReady = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }), + processFail: (error) => t("log.process.orderError", { error: extractMessage(error) }), + } + ); + + safeSubscribe( + this.exchange.watchDepth.bind(this.exchange, this.config.symbol), + (depth) => { + this.depthSnapshot = depth; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }), + processFail: (error) => t("log.process.depthError", { error: extractMessage(error) }), + } + ); + + safeSubscribe( + this.exchange.watchTicker.bind(this.exchange, this.config.symbol), + (ticker) => { + this.tickerSnapshot = ticker; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }), + processFail: (error) => t("log.process.tickerError", { error: extractMessage(error) }), + } + ); + } + + private synchronizeLocks(orders: AsterOrder[] | null | undefined): void { + const list = Array.isArray(orders) ? orders : []; + Object.keys(this.pending).forEach((type) => { + const pendingId = this.pending[type]; + if (!pendingId) return; + const match = list.find((order) => String(order.orderId) === pendingId); + if (!match || (match.status && match.status !== "NEW" && match.status !== "PARTIALLY_FILLED")) { + unlockOperating(this.locks, this.timers, this.pending, type); + } + }); + } + + private isReady(): boolean { + return Boolean( + this.accountSnapshot && + this.tickerSnapshot && + this.depthSnapshot && + this.ordersSnapshotReady && + this.binanceSnapshot.isStable && + this.binanceSnapshot.rsi != null + ); + } + + private async tick(): Promise { + if (this.processing) return; + this.processing = true; + let hadRateLimit = false; + + try { + const decision = this.rateLimit.beforeCycle(); + if (decision === "paused") { + this.emitUpdate(); + return; + } + if (decision === "skip") { + return; + } + if (this.disabled) { + this.emitUpdate(); + return; + } + if (!this.isReady()) { + this.emitUpdate(); + return; + } + + const account = this.accountSnapshot!; + const position = getPosition(account, this.config.symbol); + const { topBid, topAsk } = getTopPrices(this.depthSnapshot); + const bid = topBid ?? Number(this.tickerSnapshot?.lastPrice); + const ask = topAsk ?? Number(this.tickerSnapshot?.lastPrice); + const pnl = computePositionPnl(position, bid, ask); + const price = this.getReferencePrice(); + + const decisionOut = stepSwing( + this.swingState, + { direction: this.config.direction, rsiHigh: this.config.rsiHigh, rsiLow: this.config.rsiLow }, + { rsi: this.binanceSnapshot.rsi, positionAmt: position.positionAmt, pnl } + ); + this.swingState = decisionOut.nextState; + + for (const action of decisionOut.actions) { + if (action.type === "OPEN_SHORT") { + await this.tryOpen("SELL", action.reason); + } else if (action.type === "OPEN_LONG") { + await this.tryOpen("BUY", action.reason); + } else if (action.type === "CLOSE_POSITION") { + await this.tryClose(position, action.reason); + } + } + + // Stop-loss management / kill-switch for any open position. + await this.handleStopLoss(position, price); + + this.sessionVolume.update(position, price); + this.emitUpdate(); + } catch (error) { + if (isRateLimitError(error)) { + hadRateLimit = true; + this.rateLimit.registerRateLimit("swing"); + this.tradeLog.push("warn", `SwingEngine 429: ${String(error)}`); + } else { + this.lastError = extractMessage(error); + this.tradeLog.push("error", `SwingEngine error: ${this.lastError}`); + } + this.emitUpdate(); + } finally { + try { + this.rateLimit.onCycleComplete(hadRateLimit); + } finally { + this.processing = false; + } + } + } + + private async tryOpen(side: "BUY" | "SELL", reason: string): Promise { + try { + // Ensure flat before opening. + const position = getPosition(this.accountSnapshot, this.config.symbol); + if (Math.abs(position.positionAmt) > EPS) { + return; + } + await placeMarketOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + side, + this.config.tradeAmount, + (type, detail) => this.tradeLog.push(type, detail), + false, + { + markPrice: position.markPrice, + expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null, + maxPct: this.config.maxCloseSlippagePct, + }, + { qtyStep: this.config.qtyStep } + ); + this.tradeLog.push("open", `${reason}: ${side} (market)`); + } catch (err) { + this.tradeLog.push("error", `Open failed: ${extractMessage(err)}`); + } + } + + private async tryClose(position: PositionSnapshot, reason: string): Promise { + try { + if (Math.abs(position.positionAmt) <= EPS) return; + const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY"; + const expected = + side === "SELL" + ? Number(this.depthSnapshot?.bids?.[0]?.[0]) + : Number(this.depthSnapshot?.asks?.[0]?.[0]); + await marketClose( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + side, + Math.abs(position.positionAmt), + (type, detail) => this.tradeLog.push(type, detail), + { + markPrice: position.markPrice, + expectedPrice: Number.isFinite(expected) ? expected : Number(this.tickerSnapshot?.lastPrice) || null, + maxPct: this.config.maxCloseSlippagePct, + }, + { qtyStep: this.config.qtyStep } + ); + this.tradeLog.push("close", `${reason}: ${side} (market close)`); + } catch (err) { + if (isUnknownOrderError(err)) { + this.tradeLog.push("order", "Close skipped: order missing"); + } else { + this.tradeLog.push("error", `Close failed: ${extractMessage(err)}`); + } + } + } + + private async handleStopLoss(position: PositionSnapshot, referencePrice: number | null): Promise { + const hasPosition = Math.abs(position.positionAmt) > EPS; + if (!hasPosition) { + this.lastStopAttempt = { side: null, price: null, at: 0 }; + return; + } + + const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8; + if (!hasEntryPrice) { + return; + } + + const direction = position.positionAmt > 0 ? "long" : "short"; + const stopSide: "BUY" | "SELL" = direction === "long" ? "SELL" : "BUY"; + const stopPrice = + direction === "long" + ? position.entryPrice * (1 - Math.max(0, this.config.stopLossPct)) + : position.entryPrice * (1 + Math.max(0, this.config.stopLossPct)); + + const tick = Math.max(1e-9, this.config.priceTick); + const lastPrice = referencePrice ?? Number(this.tickerSnapshot?.lastPrice) ?? null; + + // Kill-switch (always-on). + const triggerKill = + direction === "long" + ? lastPrice != null && Number.isFinite(lastPrice) && lastPrice <= stopPrice + tick + : lastPrice != null && Number.isFinite(lastPrice) && lastPrice >= stopPrice - tick; + if (triggerKill) { + await this.tryClose(position, "Stop-loss kill-switch"); + return; + } + + // If exchange supports stop orders, keep one active. + const currentStop = this.openOrders.find((o) => { + const hasStopPrice = Number.isFinite(Number(o.stopPrice)) && Number(o.stopPrice) > 0; + return o.side === stopSide && (o.type === "STOP_MARKET" || hasStopPrice); + }); + if (currentStop) return; + + // De-bounce: avoid repeated submissions of same stop. + const now = Date.now(); + if ( + this.lastStopAttempt.side === stopSide && + this.lastStopAttempt.price != null && + Math.abs(stopPrice - Number(this.lastStopAttempt.price)) < tick && + now - this.lastStopAttempt.at < 5000 + ) { + return; + } + + try { + const qty = Math.abs(position.positionAmt); + await placeStopLossOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + stopSide, + stopPrice, + qty, + lastPrice, + (type, detail) => this.tradeLog.push(type, detail), + { + markPrice: position.markPrice, + maxPct: this.config.maxCloseSlippagePct, + }, + { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } + ); + this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() }; + } catch (err) { + this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() }; + this.tradeLog.push("error", `Failed to place stop-loss order: ${extractMessage(err)}`); + } + } + + private emitUpdate(): void { + try { + const snapshot = this.buildSnapshot(); + this.events.emit("update", snapshot, (error) => { + this.tradeLog.push("error", `SwingEngine update handler error: ${String(error)}`); + }); + } catch (err) { + this.tradeLog.push("error", `SwingEngine snapshot error: ${String(err)}`); + } + } + + private buildSnapshot(): SwingEngineSnapshot { + const position = getPosition(this.accountSnapshot, this.config.symbol); + const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null; + const { topBid, topAsk } = getTopPrices(this.depthSnapshot); + const pnl = computePositionPnl(position, topBid ?? price, topAsk ?? price); + const reference = this.getReferencePrice(); + + const hasPosition = Math.abs(position.positionAmt) > EPS; + const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8; + const stopLossTarget = hasPosition && hasEntryPrice + ? (position.positionAmt > 0 + ? position.entryPrice * (1 - Math.max(0, this.config.stopLossPct)) + : position.entryPrice * (1 + Math.max(0, this.config.stopLossPct))) + : null; + const tick = Math.max(1e-9, this.config.priceTick); + const stopLossKillSwitch = + stopLossTarget != null && reference != null && Number.isFinite(reference) + ? (position.positionAmt > 0 ? reference <= stopLossTarget + tick : reference >= stopLossTarget - tick) + : false; + + const zone: SwingRsiZone = + this.binanceSnapshot.rsi == null || !Number.isFinite(this.binanceSnapshot.rsi) + ? "unknown" + : this.binanceSnapshot.rsi > this.config.rsiHigh + ? "overbought" + : this.binanceSnapshot.rsi < this.config.rsiLow + ? "oversold" + : "neutral"; + + const posAmt = Number(position.positionAmt); + const phase: SwingPhase = this.disabled + ? "disabled" + : !this.isReady() + ? "initializing" + : Math.abs(posAmt) <= EPS + ? this.swingState.armedShortEntry + ? "waiting_open_short" + : this.swingState.armedLongEntry + ? "waiting_open_long" + : "observing" + : posAmt < -EPS + ? this.swingState.armedShortExit + ? "waiting_close_short" + : "observing" + : this.swingState.armedLongExit + ? "waiting_close_long" + : "observing"; + + return { + ready: this.isReady() && !this.disabled, + disabled: this.disabled, + symbol: this.config.symbol, + direction: this.config.direction, + + lastPrice: reference, + phase, + binancePrice: this.binanceSnapshot.lastClose, + rsi: this.binanceSnapshot.rsi, + rsiStable: this.binanceSnapshot.isStable, + rsiZone: zone, + binanceConnection: this.binanceSnapshot.connectionState, + binanceUpdatedAt: this.binanceSnapshot.updatedAt, + + armed: { + armedShortEntry: this.swingState.armedShortEntry, + armedShortExit: this.swingState.armedShortExit, + armedLongEntry: this.swingState.armedLongEntry, + armedLongExit: this.swingState.armedLongExit, + }, + + position, + pnl, + unrealized: position.unrealizedProfit, + sessionVolume: this.sessionVolume.value, + + stopLossTarget, + stopLossKillSwitch, + + openOrders: this.openOrders, + depth: this.depthSnapshot, + ticker: this.tickerSnapshot, + + tradeLog: this.tradeLog.all(), + lastUpdated: Date.now(), + error: this.lastError, + }; + } + + private getReferencePrice(): number | null { + return ( + getMidOrLast(this.depthSnapshot, this.tickerSnapshot) ?? + (this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null) + ); + } + + private syncPrecision(): void { + if (this.precisionSync) return; + const getPrecision = this.exchange.getPrecision?.bind(this.exchange); + if (!getPrecision) return; + this.precisionSync = getPrecision() + .then((precision) => { + if (!precision) return; + let updated = false; + if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) { + const delta = Math.abs(precision.priceTick - this.config.priceTick); + if (delta > 1e-12) { + this.config.priceTick = precision.priceTick; + updated = true; + } + } + if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) { + const delta = Math.abs(precision.qtyStep - this.config.qtyStep); + if (delta > 1e-12) { + this.config.qtyStep = precision.qtyStep; + updated = true; + } + } + if (updated) { + this.tradeLog.push( + "info", + `Synced precision: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}` + ); + } + }) + .catch((error) => { + this.tradeLog.push("error", `Precision sync failed: ${extractMessage(error)}`); + this.precisionSync = null; + setTimeout(() => this.syncPrecision(), 2000); + }); + } +} + diff --git a/src/strategy/swing-logic.test.ts b/src/strategy/swing-logic.test.ts new file mode 100644 index 0000000..76aa299 --- /dev/null +++ b/src/strategy/swing-logic.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { createInitialSwingState, stepSwing, type SwingLogicConfig } from "./swing-logic"; + +const baseConfig: SwingLogicConfig = { + direction: "both", + rsiHigh: 70, + rsiLow: 30, +}; + +describe("swing logic", () => { + it("arms short entry on RSI cross up 70, then opens on cross down 70", () => { + let state = createInitialSwingState(); + + // First observation sets prevRsi, no cross yet. + ({ nextState: state } = stepSwing(state, { ...baseConfig, direction: "short" }, { rsi: 69, positionAmt: 0, pnl: 0 })); + const a1 = stepSwing(state, { ...baseConfig, direction: "short" }, { rsi: 71, positionAmt: 0, pnl: 0 }); + expect(a1.actions).toEqual([]); + expect(a1.nextState.armedShortEntry).toBe(true); + state = a1.nextState; + + const a2 = stepSwing(state, { ...baseConfig, direction: "short" }, { rsi: 69, positionAmt: 0, pnl: 0 }); + expect(a2.actions.map((a) => a.type)).toEqual(["OPEN_SHORT"]); + expect(a2.nextState.armedShortEntry).toBe(false); + }); + + it("arms long entry on RSI cross down 30, then opens on cross up 30", () => { + let state = createInitialSwingState(); + ({ nextState: state } = stepSwing(state, { ...baseConfig, direction: "long" }, { rsi: 31, positionAmt: 0, pnl: 0 })); + + const a1 = stepSwing(state, { ...baseConfig, direction: "long" }, { rsi: 29, positionAmt: 0, pnl: 0 }); + expect(a1.actions).toEqual([]); + expect(a1.nextState.armedLongEntry).toBe(true); + state = a1.nextState; + + const a2 = stepSwing(state, { ...baseConfig, direction: "long" }, { rsi: 31, positionAmt: 0, pnl: 0 }); + expect(a2.actions.map((a) => a.type)).toEqual(["OPEN_LONG"]); + expect(a2.nextState.armedLongEntry).toBe(false); + }); + + it("short exit requires profit: arms on cross down 30, closes on cross up 30 if pnl > 0", () => { + let state = createInitialSwingState(); + ({ nextState: state } = stepSwing(state, baseConfig, { rsi: 31, positionAmt: -1, pnl: -1 })); + + const a1 = stepSwing(state, baseConfig, { rsi: 29, positionAmt: -1, pnl: -1 }); + expect(a1.actions).toEqual([]); + expect(a1.nextState.armedShortExit).toBe(true); + state = a1.nextState; + + const a2 = stepSwing(state, baseConfig, { rsi: 31, positionAmt: -1, pnl: 0 }); + expect(a2.actions).toEqual([]); // pnl not strictly positive + expect(a2.nextState.armedShortExit).toBe(true); + state = a2.nextState; + + const a3 = stepSwing(state, baseConfig, { rsi: 31, positionAmt: -1, pnl: 0.01 }); + // No cross (prev=31 -> 31), still armed. + expect(a3.actions).toEqual([]); + + // Cross down then up to trigger close with profit + const a4 = stepSwing(a3.nextState, baseConfig, { rsi: 29, positionAmt: -1, pnl: 0.01 }); + const a5 = stepSwing(a4.nextState, baseConfig, { rsi: 31, positionAmt: -1, pnl: 0.01 }); + expect(a5.actions.map((a) => a.type)).toEqual(["CLOSE_POSITION"]); + expect(a5.nextState.armedShortExit).toBe(false); + }); + + it("long exit requires profit: arms on cross up 70, closes on cross down 70 if pnl > 0", () => { + let state = createInitialSwingState(); + ({ nextState: state } = stepSwing(state, baseConfig, { rsi: 69, positionAmt: 1, pnl: 0 })); + + const a1 = stepSwing(state, baseConfig, { rsi: 71, positionAmt: 1, pnl: 0 }); + expect(a1.actions).toEqual([]); + expect(a1.nextState.armedLongExit).toBe(true); + state = a1.nextState; + + const a2 = stepSwing(state, baseConfig, { rsi: 69, positionAmt: 1, pnl: 0.01 }); + expect(a2.actions.map((a) => a.type)).toEqual(["CLOSE_POSITION"]); + expect(a2.nextState.armedLongExit).toBe(false); + }); + + it("clears entry arms when a position is present", () => { + let state = createInitialSwingState(); + ({ nextState: state } = stepSwing(state, baseConfig, { rsi: 69, positionAmt: 0, pnl: 0 })); + + // Arm short entry. + state = stepSwing(state, baseConfig, { rsi: 71, positionAmt: 0, pnl: 0 }).nextState; + expect(state.armedShortEntry).toBe(true); + + // Now position appears: entry arms should be reset. + const next = stepSwing(state, baseConfig, { rsi: 71, positionAmt: -1, pnl: 0 }); + expect(next.nextState.armedShortEntry).toBe(false); + expect(next.nextState.armedLongEntry).toBe(false); + }); +}); + diff --git a/src/strategy/swing-logic.ts b/src/strategy/swing-logic.ts new file mode 100644 index 0000000..7f32a36 --- /dev/null +++ b/src/strategy/swing-logic.ts @@ -0,0 +1,151 @@ +export type SwingDirection = "long" | "short" | "both"; + +export interface SwingLogicConfig { + direction: SwingDirection; + rsiHigh: number; // e.g. 70 + rsiLow: number; // e.g. 30 +} + +export interface SwingState { + prevRsi: number | null; + armedShortEntry: boolean; + armedShortExit: boolean; + armedLongEntry: boolean; + armedLongExit: boolean; +} + +export type SwingAction = + | { type: "OPEN_SHORT"; reason: string } + | { type: "OPEN_LONG"; reason: string } + | { type: "CLOSE_POSITION"; reason: string }; + +export interface SwingStepInput { + rsi: number | null; + positionAmt: number; + pnl: number; +} + +export function createInitialSwingState(): SwingState { + return { + prevRsi: null, + armedShortEntry: false, + armedShortExit: false, + armedLongEntry: false, + armedLongExit: false, + }; +} + +const EPS = 1e-8; + +function crossUp(prev: number | null, next: number, threshold: number): boolean { + if (prev == null) return false; + return prev <= threshold && next > threshold; +} + +function crossDown(prev: number | null, next: number, threshold: number): boolean { + if (prev == null) return false; + return prev >= threshold && next < threshold; +} + +export function stepSwing( + state: SwingState, + config: SwingLogicConfig, + input: SwingStepInput +): { nextState: SwingState; actions: SwingAction[] } { + const nextState: SwingState = { ...state }; + const actions: SwingAction[] = []; + + const rsi = input.rsi; + const hasRsi = typeof rsi === "number" && Number.isFinite(rsi); + const prevRsi = nextState.prevRsi; + + const direction = config.direction; + const allowLong = direction === "long" || direction === "both"; + const allowShort = direction === "short" || direction === "both"; + + const positionAmt = Number(input.positionAmt); + const pnl = Number(input.pnl); + const isFlat = !Number.isFinite(positionAmt) || Math.abs(positionAmt) <= EPS; + const isLong = Number.isFinite(positionAmt) && positionAmt > EPS; + const isShort = Number.isFinite(positionAmt) && positionAmt < -EPS; + + // If RSI is missing/invalid, avoid mutating state. + if (!hasRsi) { + return { nextState, actions }; + } + + // Keep prevRsi updated once we have a valid reading. + nextState.prevRsi = rsi; + + if (isFlat) { + // When flat, exit arms are irrelevant. + nextState.armedShortExit = false; + nextState.armedLongExit = false; + + if (!allowShort) { + nextState.armedShortEntry = false; + } else { + if (crossUp(prevRsi, rsi, config.rsiHigh)) { + nextState.armedShortEntry = true; + } + if (nextState.armedShortEntry && crossDown(prevRsi, rsi, config.rsiHigh)) { + actions.push({ type: "OPEN_SHORT", reason: "RSI armed above high, then crossed below high" }); + nextState.armedShortEntry = false; + // Avoid impossible dual-entries. + nextState.armedLongEntry = false; + } + } + + if (!allowLong) { + nextState.armedLongEntry = false; + } else { + if (crossDown(prevRsi, rsi, config.rsiLow)) { + nextState.armedLongEntry = true; + } + if (nextState.armedLongEntry && crossUp(prevRsi, rsi, config.rsiLow)) { + actions.push({ type: "OPEN_LONG", reason: "RSI armed below low, then crossed above low" }); + nextState.armedLongEntry = false; + nextState.armedShortEntry = false; + } + } + + if (actions.length > 1) { + // Defensive: avoid opening both directions in one step. + return { nextState: { ...nextState, armedShortEntry: false, armedLongEntry: false }, actions: [] }; + } + + return { nextState, actions }; + } + + // When exposed, entry arms are irrelevant (strategy does not pyramid). + nextState.armedShortEntry = false; + nextState.armedLongEntry = false; + + // Exits are always allowed (even if direction config changes) to avoid trapping positions. + if (isShort) { + nextState.armedLongExit = false; + if (crossDown(prevRsi, rsi, config.rsiLow)) { + nextState.armedShortExit = true; + } + if (nextState.armedShortExit && crossUp(prevRsi, rsi, config.rsiLow) && pnl > 0) { + actions.push({ type: "CLOSE_POSITION", reason: "RSI exit armed below low, then crossed above low with profit" }); + nextState.armedShortExit = false; + } + return { nextState, actions }; + } + + if (isLong) { + nextState.armedShortExit = false; + if (crossUp(prevRsi, rsi, config.rsiHigh)) { + nextState.armedLongExit = true; + } + if (nextState.armedLongExit && crossDown(prevRsi, rsi, config.rsiHigh) && pnl > 0) { + actions.push({ type: "CLOSE_POSITION", reason: "RSI exit armed above high, then crossed below high with profit" }); + nextState.armedLongExit = false; + } + return { nextState, actions }; + } + + return { nextState, actions }; +} + diff --git a/src/ui/App.tsx b/src/ui/App.tsx index f21c996..8cd2876 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from "react"; import { Box, Text, useInput } from "ink"; import { TrendApp } from "./TrendApp"; +import { SwingApp } from "./SwingApp"; import { GuardianApp } from "./GuardianApp"; import { MakerApp } from "./MakerApp"; import { MakerPointsApp } from "./MakerPointsApp"; @@ -14,7 +15,7 @@ import { resolveExchangeId } from "../exchanges/create-adapter"; import { t } from "../i18n"; interface StrategyOption { - id: "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid"; + id: "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid"; label: string; description: string; component: React.ComponentType<{ onExit: () => void }>; @@ -27,6 +28,12 @@ const BASE_STRATEGIES: StrategyOption[] = [ description: t("app.strategy.trend.desc"), component: TrendApp, }, + { + id: "swing", + label: t("app.strategy.swing.label"), + description: t("app.strategy.swing.desc"), + component: SwingApp, + }, { id: "guardian", label: t("app.strategy.guardian.label"), @@ -70,7 +77,9 @@ export function App() { const strategies = useMemo(() => { const next: StrategyOption[] = [...BASE_STRATEGIES]; if (exchangeId === "standx") { - next.splice(3, 0, { + const gridIndex = next.findIndex((s) => s.id === "grid"); + const insertAt = gridIndex === -1 ? next.length : gridIndex; + next.splice(insertAt, 0, { id: "maker-points" as const, label: t("app.strategy.makerPoints.label"), description: t("app.strategy.makerPoints.desc"), diff --git a/src/ui/SwingApp.tsx b/src/ui/SwingApp.tsx new file mode 100644 index 0000000..0e79524 --- /dev/null +++ b/src/ui/SwingApp.tsx @@ -0,0 +1,217 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { swingConfig } from "../config"; +import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; +import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; +import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine"; +import { formatNumber } from "../utils/format"; +import { DataTable, type TableColumn } from "./components/DataTable"; +import { t } from "../i18n"; + +const READY_MESSAGE = t("swing.readyMessage"); + +interface SwingAppProps { + onExit: () => void; +} + +const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); + +export function SwingApp({ onExit }: SwingAppProps) { + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + const engineRef = useRef(null); + const exchangeId = useMemo(() => resolveExchangeId(), []); + const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); + + useInput( + (_input, key) => { + if (key.escape) { + engineRef.current?.stop(); + onExit(); + } + }, + { isActive: inputSupported } + ); + + useEffect(() => { + try { + const adapter = buildAdapterFromEnv({ exchangeId, symbol: swingConfig.symbol }); + const engine = new SwingEngine(swingConfig, adapter); + engineRef.current = engine; + setSnapshot(engine.getSnapshot()); + const handler = (next: SwingEngineSnapshot) => { + setSnapshot({ ...next, tradeLog: [...next.tradeLog], openOrders: [...next.openOrders] }); + }; + engine.on("update", handler); + engine.start(); + return () => { + engine.off("update", handler); + engine.stop(); + }; + } catch (err) { + console.error(err); + setError(err instanceof Error ? err : new Error(String(err))); + } + }, [exchangeId]); + + if (error) { + return ( + + {t("common.startFailed", { message: error.message })} + {t("common.checkEnv")} + + ); + } + + if (!snapshot) { + return ( + + {t("common.initializing", { target: t("swing.name") })} + + ); + } + + const zoneLabel = + snapshot.rsiZone === "overbought" + ? t("swing.zone.overbought") + : snapshot.rsiZone === "oversold" + ? t("swing.zone.oversold") + : snapshot.rsiZone === "neutral" + ? t("swing.zone.neutral") + : t("swing.zone.unknown"); + + const phaseLabel = + snapshot.phase === "disabled" + ? t("swing.phase.disabled") + : snapshot.phase === "initializing" + ? t("swing.phase.initializing") + : snapshot.phase === "waiting_open_short" + ? t("swing.phase.waitingOpenShort") + : snapshot.phase === "waiting_close_short" + ? t("swing.phase.waitingCloseShort") + : snapshot.phase === "waiting_open_long" + ? t("swing.phase.waitingOpenLong") + : snapshot.phase === "waiting_close_long" + ? t("swing.phase.waitingCloseLong") + : t("swing.phase.observing"); + + const lastLogs = snapshot.tradeLog.slice(-5); + const sortedOrders = [...snapshot.openOrders].sort( + (a, b) => (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId) + ); + const orderRows = sortedOrders.slice(0, 8).map((order) => ({ + id: order.orderId, + side: order.side, + type: order.type, + price: order.price, + qty: order.origQty, + filled: order.executedQty, + status: order.status, + })); + const orderColumns: TableColumn[] = [ + { key: "id", header: "ID", align: "right", minWidth: 6 }, + { key: "side", header: "Side", minWidth: 4 }, + { key: "type", header: "Type", minWidth: 10 }, + { key: "price", header: "Price", align: "right", minWidth: 10 }, + { key: "qty", header: "Qty", align: "right", minWidth: 8 }, + { key: "filled", header: "Filled", align: "right", minWidth: 8 }, + { key: "status", header: "Status", minWidth: 10 }, + ]; + + const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5; + + return ( + + + {t("swing.title")} + + {t("swing.headerLine", { + exchange: exchangeName, + symbol: snapshot.symbol, + direction: snapshot.direction, + lastPrice: formatNumber(snapshot.lastPrice, 6), + phase: phaseLabel, + })} + + + {t("swing.signalLine", { + binanceSymbol: "ETHBTC", + binancePrice: formatNumber(snapshot.binancePrice, 8), + rsi: formatNumber(snapshot.rsi, 2), + zone: zoneLabel, + connection: snapshot.binanceConnection, + })} + + + {t("swing.statusLine", { + status: snapshot.disabled + ? t("status.paused") + : snapshot.ready + ? t("status.live") + : READY_MESSAGE, + })} + + {snapshot.error ? {snapshot.error} : null} + + + + + {t("common.section.position")} + {hasPosition ? ( + <> + + {t("swing.positionLine", { + direction: snapshot.position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"), + qty: formatNumber(Math.abs(snapshot.position.positionAmt), 4), + entry: formatNumber(snapshot.position.entryPrice, 6), + })} + + + {t("swing.pnlLine", { + pnl: formatNumber(snapshot.pnl, 4), + unrealized: formatNumber(snapshot.unrealized, 4), + })} + + + {t("swing.stopLine", { stop: formatNumber(snapshot.stopLossTarget, 6) })} + + + ) : ( + {t("common.noPosition")} + )} + + + {t("swing.stateTitle")} + + {t("swing.armedLine", { + se: snapshot.armed.armedShortEntry ? "Y" : "N", + sx: snapshot.armed.armedShortExit ? "Y" : "N", + le: snapshot.armed.armedLongEntry ? "Y" : "N", + lx: snapshot.armed.armedLongExit ? "Y" : "N", + })} + + {t("swing.volumeLine", { volume: formatNumber(snapshot.sessionVolume, 2) })} + + + + + {t("common.section.orders")} + {orderRows.length > 0 ? : {t("common.noOrders")}} + + + + {t("common.section.recentTrades")} + {lastLogs.length > 0 ? ( + lastLogs.map((item, index) => ( + + [{item.time}] [{item.type}] {item.detail} + + )) + ) : ( + {t("common.noLogs")} + )} + + + ); +} +