diff --git a/skills/kuns9/trading-upbit-skill/.env.example b/skills/kuns9/trading-upbit-skill/.env.example new file mode 100644 index 0000000000..fa677b9adc --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/.env.example @@ -0,0 +1,9 @@ +# Upbit API Keys +UPBIT_OPEN_API_ACCESS_KEY=your_access_key_here +UPBIT_OPEN_API_SECRET_KEY=your_secret_key_here + +# Bot Settings +PRICE_CHECK_INTERVAL=10000 +TARGET_PROFIT=0.05 +STOP_LOSS=-0.05 +AUTO_TRADE=false \ No newline at end of file diff --git a/skills/kuns9/trading-upbit-skill/README.md b/skills/kuns9/trading-upbit-skill/README.md new file mode 100644 index 0000000000..128ec8ea61 --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/README.md @@ -0,0 +1,68 @@ +# Upbit Trading Engine ๐Ÿš€ + +์•ˆ์ •์„ฑ๊ณผ ํ™•์žฅ์„ฑ์„ ์ตœ์šฐ์„ ์œผ๋กœ ์„ค๊ณ„๋œ ์—…๋น„ํŠธ ์ž๋™ ๋งค๋งค ์—”์ง„์ž…๋‹ˆ๋‹ค. SKILLS ๊ทœ๊ฒฉ์„ ์ค€์ˆ˜ํ•˜์—ฌ ์ฒด๊ณ„์ ์œผ๋กœ ์ฝ”๋“œ๋ฅผ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +## ์ฃผ์š” ํŠน์ง• + +- ๐Ÿ—๏ธ **SKILLS ๊ตฌ์กฐ**: `scripts/`(๋กœ์ง), `resources/`(๋ฐ์ดํ„ฐ), `backup/`(๋ฐฑ์—…)์œผ๋กœ ์ฒด๊ธฐ์  ๊ด€๋ฆฌ +- ๐Ÿ›ก๏ธ **๊ฒฌ๊ณ ํ•œ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ**: `upbitClient.js`๋ฅผ ํ†ตํ•œ ์ค‘์•™ ์ง‘์ค‘์‹ ์—๋Ÿฌ ๊ด€๋ฆฌ ๋ฐ **429(Rate Limit) ์ž๋™ ์žฌ์‹œ๋„** +- ๐Ÿ” **๋‹ค์ด๋‚˜๋ฏน ์Šค์ผ€๋„ˆ**: ์—…๋น„ํŠธ์˜ ๋ชจ๋“  KRW ๋งˆ์ผ“์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ž๋™ ํƒ์ƒ‰ํ•˜์—ฌ ๊ธฐํšŒ ํฌ์ฐฉ +- ๐Ÿš€ **์ด์ค‘ ํ™•์ธ ์ „๋žต**: 1์ผ๋ด‰ ๋ŒํŒŒ ์‹ ํ˜ธ๋ฅผ 60๋ถ„๋ด‰ ์ถ”์„ธ๋กœ ๊ฒ€์ฆํ•˜๋Š” Multi-Timeframe ๋ถ„์„ +- ๐Ÿ”„ **์ƒํƒœ ๋จธ์‹ **: `FLAT` -> `ENTRY_PENDING` -> `OPEN` -> `EXIT_PENDING` -> `CLOSED` ์ƒํƒœ ๊ด€๋ฆฌ + +## ์‹œ์ž‘ํ•˜๊ธฐ + +### ์„ค์น˜ + +```bash +# ์ €์žฅ์†Œ ๋ณต์ œ +git clone +cd trading-upbit-skill + +# ์˜์กด์„ฑ ์„ค์น˜ +npm install +``` + +### ์„ค์ • + +1. [Upbit API ํ‚ค ๋ฐœ๊ธ‰](https://upbit.com/mypage/open_api_management) +2. `.env` ํŒŒ์ผ ์„ค์ •: + ```bash + cp .env.example .env + # .env ํŒŒ์ผ์„ ์—ด์–ด ๋ฐœ๊ธ‰๋ฐ›์€ Access Key์™€ Secret Key๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”. + ``` + +### ์‹คํ–‰ + +์‹œ์Šคํ…œ์€ ๋ชจ๋‹ˆํ„ฐ์™€ ์ด๋ฒคํŠธ ์›Œ์ปค ๋‘ ๊ฐ€์ง€ ํ”„๋กœ์„ธ์Šค๋กœ ๊ตฌ์„ฑ๋ฉ๋‹ˆ๋‹ค. ๋ณ„๋„์˜ ํ„ฐ๋ฏธ๋„์—์„œ ๊ฐ๊ฐ ์‹คํ–‰ํ•ด ์ฃผ์„ธ์š”. + +```bash +# ๋งˆ์ผ“ ์Šค์บ” ๋ฐ ํฌ์ง€์…˜ ๊ฐ์‹œ +node scripts/workers/monitor.js + +# ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋ฐ ๋งค๋งค ์‹คํ–‰ +node scripts/workers/eventWorker.js +``` + +## ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ + +- `scripts/`: ํ•ต์‹ฌ ์‹คํ–‰ ๋กœ์ง ๋ฐ ๋ชจ๋“ˆ + - `workers/`: ๋ฐฐ๊ฒฝ์—์„œ ๋™์ž‘ํ•˜๋Š” ์ž‘์—…์ž ํ”„๋กœ์„ธ์Šค + - `execution/`: ๋งค๋งค ์‹คํ–‰ ์—”์ง„ + - `risk/`: ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ ํ•„ํ„ฐ + - `state/`: ํฌ์ง€์…˜ ๋ฐ ์ƒํƒœ ๊ด€๋ฆฌ +- `resources/`: `positions.json`, `events.json` ๋“ฑ ๋™์  ๋ฐ์ดํ„ฐ ์ €์žฅ์†Œ +- `backup/`: ์ด์ „ ์ฝ”๋“œ ๋ฐ ๋กœ๊ทธ ๋ฐฑ์—… +- `examples/`: ์ฐธ๊ณ ์šฉ ์˜ˆ์ œ ์ฝ”๋“œ + +## Core Scripts + +- `scripts/workers/monitor.js`: ๋™์  ๋งˆ์ผ“ ์Šค์บ” ๋ฐ ํฌ์ง€์…˜ ๊ฐ์‹œ +- `scripts/workers/eventWorker.js`: ์ด๋ฒคํŠธ ์ „ํŒŒ ๋ฐ ์ฒ˜๋ฆฌ ํ”„๋กœ์„ธ์„œ +- `scripts/execution/tradeExecutor.js`: ์ƒํƒœ ์ „์ด ๊ธฐ๋ฐ˜ ๋งค๋งค ์—”์ง„ +- `scripts/risk/riskManager.js`: ๋ฆฌ์Šคํฌ ํ‰๊ฐ€ ๋ฐ ์‚ฌ์ด์ง• ํ•„ํ„ฐ +- `scripts/state/positionsRepo.js`: ์ƒํƒœ ๋จธ์‹  ์˜์†์„ฑ ๊ด€๋ฆฌ + +## ๋ผ์ด์„ ์Šค + +์ด ํ”„๋กœ์ ํŠธ๋Š” ISC ๋ผ์ด์„ ์Šค๋ฅผ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค. diff --git a/skills/kuns9/trading-upbit-skill/SKILL.md b/skills/kuns9/trading-upbit-skill/SKILL.md new file mode 100644 index 0000000000..3e1dbb29a5 --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/SKILL.md @@ -0,0 +1,61 @@ +--- +name: Upbit Trading Engine +description: ์•ˆ์ •์„ฑ๊ณผ ํ™•์žฅ์„ฑ์„ ์ตœ์šฐ์„ ์œผ๋กœ ์„ค๊ณ„๋œ ์—…๋น„ํŠธ ์ž๋™ ๋งค๋งค ์—”์ง„ (SKILLS ๊ทœ๊ฒฉ ์ค€์ˆ˜) +metadata: + clawdbot: + requires: + env: + - UPBIT_OPEN_API_ACCESS_KEY + - UPBIT_OPEN_API_SECRET_KEY + - PRICE_CHECK_INTERVAL + - TARGET_PROFIT + - STOP_LOSS + - AUTO_TRADE +files: + - "skill.js" + - "scripts/**" + - "resources/**" +homepage: "https://github.com/sgyeo/trading-upbit-skill" +--- + +# Upbit Trading Engine ๐Ÿš€ + +์•ˆ์ •์„ฑ๊ณผ ํ™•์žฅ์„ฑ์„ ์ตœ์šฐ์„ ์œผ๋กœ ์„ค๊ณ„๋œ ์—…๋น„ํŠธ ์ž๋™ ๋งค๋งค ์—”์ง„์ž…๋‹ˆ๋‹ค. (SKILLS ๊ทœ๊ฒฉ ์ค€์ˆ˜) + +## Key Features + +- ๐Ÿ—๏ธ **SKILLS ๊ตฌ์กฐ**: `scripts/`(๋กœ์ง), `resources/`(๋ฐ์ดํ„ฐ), `backup/`(๋ฐฑ์—…)์œผ๋กœ ์ฒด๊ณ„์  ๊ด€๋ฆฌ +- ๐Ÿ›ก๏ธ **๊ฒฌ๊ณ ํ•œ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ**: `upbitClient.js`๋ฅผ ํ†ตํ•œ ์ค‘์•™ ์ง‘์ค‘์‹ ์—๋Ÿฌ ๊ด€๋ฆฌ ๋ฐ **429(Rate Limit) ์ž๋™ ์žฌ์‹œ๋„** +- ๐Ÿ” **๋‹ค์ด๋‚˜๋ฏน ์Šค์ผ€๋„ˆ**: ์—…๋น„ํŠธ์˜ ๋ชจ๋“  KRW ๋งˆ์ผ“์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ž๋™ ํƒ์ƒ‰ํ•˜์—ฌ ๊ธฐํšŒ ํฌ์ฐฉ +- ๐Ÿš€ **์ด์ค‘ ํ™•์ธ ์ „๋žต**: 1์ผ๋ด‰ ๋ŒํŒŒ ์‹ ํ˜ธ๋ฅผ 60๋ถ„๋ด‰ ์ถ”์„ธ๋กœ ๊ฒ€์ฆํ•˜๋Š” Multi-Timeframe ๋ถ„์„ +- ๐Ÿ”„ **์ƒํƒœ ๋จธ์‹ **: `FLAT` -> `ENTRY_PENDING` -> `OPEN` -> `EXIT_PENDING` -> `CLOSED` ์ƒํƒœ ๊ด€๋ฆฌ + +## Setup + +1. [Upbit API ํ‚ค ๋ฐœ๊ธ‰](https://upbit.com/mypage/open_api_management) +2. ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์„ค์ •: +```bash +cp .env.example .env +``` + +3. ์‹คํ–‰: +```bash +# ๋ณ„๋„์˜ ํ„ฐ๋ฏธ๋„์—์„œ ๊ฐ๊ฐ ์‹คํ–‰ +node scripts/workers/monitor.js +node scripts/workers/eventWorker.js +``` + +## Directory Structure + +- `scripts/`: ์‹คํ–‰ ๋กœ์ง ๋ฐ ํ•ต์‹ฌ ๋ชจ๋“ˆ +- `resources/`: `positions.json`, `events.json` ๋“ฑ ๋™์  ๋ฐ์ดํ„ฐ +- `backup/`: ์ด์ „ ์ฝ”๋“œ ๋ฐ ๋กœ๊ทธ ๋ฐฑ์—… +- `examples/`: ์ฐธ๊ณ ์šฉ ์˜ˆ์ œ ๋ฐ ์„ค์ • + +## Core Scripts + +- `scripts/workers/monitor.js`: ๋™์  ๋งˆ์ผ“ ์Šค์บ” ๋ฐ ํฌ์ง€์…˜ ๊ฐ์‹œ +- `scripts/workers/eventWorker.js`: ์ด๋ฒคํŠธ ์ „ํŒŒ ๋ฐ ์ฒ˜๋ฆฌ ํ”„๋กœ์„ธ์„œ +- `scripts/execution/tradeExecutor.js`: ์ƒํƒœ ์ „์ด ๊ธฐ๋ฐ˜ ๋งค๋งค ์—”์ง„ +- `scripts/risk/riskManager.js`: ๋ฆฌ์Šคํฌ ํ‰๊ฐ€ ๋ฐ ์‚ฌ์ด์ง• ํ•„ํ„ฐ +- `scripts/state/positionsRepo.js`: ์ƒํƒœ ๋จธ์‹  ์˜์†์„ฑ ๊ด€๋ฆฌ diff --git a/skills/kuns9/trading-upbit-skill/package.json b/skills/kuns9/trading-upbit-skill/package.json new file mode 100644 index 0000000000..ed2356c436 --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/package.json @@ -0,0 +1,19 @@ +{ + "name": "trading-upbit-skill", + "version": "1.0.0", + "description": "", + "main": "balance.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "axios": "^1.13.5", + "dotenv": "^17.3.1", + "jsonwebtoken": "^9.0.3", + "uuid": "^13.0.0" + } +} \ No newline at end of file diff --git a/skills/kuns9/trading-upbit-skill/scripts/data/marketData.js b/skills/kuns9/trading-upbit-skill/scripts/data/marketData.js new file mode 100644 index 0000000000..a76deea825 --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/scripts/data/marketData.js @@ -0,0 +1,82 @@ +const { request } = require('./api-client'); + +/** + * ์—…๋น„ํŠธ ๊ณต์šฉ ์‹œ์„ธ ์กฐํšŒ ๋ชจ๋“ˆ (market.js) + */ + +const BASE_URL = 'https://api.upbit.com/v1'; + +/** + * ๋งˆ์ผ“ ์ฝ”๋“œ ์กฐํšŒ (์ „์ฒด ์ข…๋ชฉ ๋ฆฌ์ŠคํŠธ) + */ +async function getMarkets(filterKRW = true) { + const data = await request({ + method: 'get', + url: `${BASE_URL}/market/all?isDetails=false` + }); + + return filterKRW ? data.filter(m => m.market.startsWith('KRW-')) : data; +} + +/** + * ์บ”๋“ค ์กฐํšŒ (ํ†ตํ•ฉ ํ•จ์ˆ˜) + */ +async function getCandles(unit, market, count = 1, subUnit = 1) { + let url = `${BASE_URL}/candles/${unit}`; + if (unit === 'minutes') { + url += `/${subUnit}`; + } + + return request({ + method: 'get', + url, + params: { market, count } + }); +} + +/** + * ํ˜„์žฌ๊ฐ€ ์ •๋ณด ์กฐํšŒ (Ticker) + */ +async function getTickers(markets) { + const marketsStr = Array.isArray(markets) ? markets.join(',') : markets; + return request({ + method: 'get', + url: `${BASE_URL}/ticker`, + params: { markets: marketsStr } + }); +} + +/** + * ํ˜ธ๊ฐ€ ์กฐํšŒ (Orderbook) + */ +async function getOrderbooks(markets) { + const marketsStr = Array.isArray(markets) ? markets.join(',') : markets; + return request({ + method: 'get', + url: `${BASE_URL}/orderbook`, + params: { markets: marketsStr } + }); +} + +module.exports = { + getMarkets, + getCandles, + getTickers, + getOrderbooks +}; + +// ํ…Œ์ŠคํŠธ ์ฝ”๋“œ +if (require.main === module) { + (async () => { + try { + console.log('--- Market Module Test ---'); + const btcTicker = await getTickers('KRW-BTC'); + console.log('BTC Ticker:', btcTicker[0].trade_price); + + const krwMarkets = await getMarkets(); + console.log('KRW Markets Count:', krwMarkets.length); + } catch (err) { + console.error('Test Failed:', err.message); + } + })(); +} diff --git a/skills/kuns9/trading-upbit-skill/scripts/execution/orderService.js b/skills/kuns9/trading-upbit-skill/scripts/execution/orderService.js new file mode 100644 index 0000000000..b63804f3e9 --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/scripts/execution/orderService.js @@ -0,0 +1,69 @@ +/** + * ์ฃผ๋ฌธ ์„œ๋น„์Šค (orderService.js) + * - ์‹œ์žฅ๊ฐ€ ๋งค์ˆ˜(Price) / ์‹œ์žฅ๊ฐ€ ๋งค๋„(Market) ์ตœ์ ํ™” + * - UpbitClient๋ฅผ ์ด์šฉํ•œ ์‹ค์ œ API ํ˜ธ์ถœ + */ + +const { Logger } = require('./upbitClient'); + +class OrderService { + constructor(upbitClient) { + this.client = upbitClient; + } + + /** + * ์‹œ์žฅ๊ฐ€ ๋งค์ˆ˜ (Entry) + * @param {string} market - ๋งˆ์ผ“ ์ฝ”๋“œ + * @param {number} totalKRW - ์ด ์ฃผ๋ฌธ ๊ธˆ์•ก + */ + async placeMarketBuy(market, totalKRW) { + Logger.info(`[BUY] ์‹œ์žฅ๊ฐ€ ๋งค์ˆ˜ ์‹œ๋„: ${market} - ${totalKRW.toLocaleString()} KRW`); + + // ์‹œ์žฅ๊ฐ€ ๋งค์ˆ˜๋Š” ord_type: 'price' + // volume์€ ์—†์–ด์•ผ ํ•˜๋ฉฐ price๊ฐ€ ์ด KRW ๊ธˆ์•ก์ด ๋จ + const orderData = { + market, + side: 'bid', + price: totalKRW.toString(), + ord_type: 'price' + }; + + return this.client.request('POST', '/orders', orderData); + } + + /** + * ์‹œ์žฅ๊ฐ€ ๋งค๋„ (Exit) + * @param {string} market - ๋งˆ์ผ“ ์ฝ”๋“œ + * @param {number} volume - ๋งค๋„ ์ˆ˜๋Ÿ‰ + */ + async placeMarketSell(market, volume) { + Logger.info(`[SELL] ์‹œ์žฅ๊ฐ€ ๋งค๋„ ์‹œ๋„: ${market} - ${volume}`); + + // ์‹œ์žฅ๊ฐ€ ๋งค๋„๋Š” ord_type: 'market' + // price๋Š” ์—†์–ด์•ผ ํ•˜๋ฉฐ volume์ด ์ˆ˜๋Ÿ‰์ด ๋จ + const orderData = { + market, + side: 'ask', + volume: volume.toString(), + ord_type: 'market' + }; + + return this.client.request('POST', '/orders', orderData); + } + + /** + * ์ฃผ๋ฌธ ์กฐํšŒ + */ + async getOrder(uuid) { + return this.client.request('GET', '/order', {}, { uuid }); + } + + /** + * ์ฃผ๋ฌธ ์ทจ์†Œ + */ + async cancelOrder(uuid) { + return this.client.request('DELETE', '/order', {}, { uuid }); + } +} + +module.exports = OrderService; diff --git a/skills/kuns9/trading-upbit-skill/scripts/execution/tradeExecutor.js b/skills/kuns9/trading-upbit-skill/scripts/execution/tradeExecutor.js new file mode 100644 index 0000000000..d3c654e05e --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/scripts/execution/tradeExecutor.js @@ -0,0 +1,69 @@ +/** + * ํ†ตํ•ฉ ๋งค๋งค ์‹คํ–‰์ž (tradeExecutor.js) + * - ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ ํ™•์ธ + * - ์ฃผ๋ฌธ ์‹คํ–‰ ๋ฐ ์ƒํƒœ ์ „์ด (PositionsRepo ํ™œ์šฉ) + */ + +const { Logger } = require('./upbitClient'); +const riskManager = require('../risk/riskManager'); +const positionsRepo = require('../state/positionsRepo'); + +class TradeExecutor { + constructor(orderService) { + this.orderService = orderService; + } + + async execute(event) { + Logger.info(`[EXECUTOR] ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์‹œ์ž‘: ${event.type} - ${event.market}`); + + try { + // 1. ๋ฆฌ์Šคํฌ ํ‰๊ฐ€ + const riskResult = await riskManager.evaluate(this.orderService.client, event); + if (!riskResult.allow) { + Logger.warn(`[SKIP] ๋ฆฌ์Šคํฌ ํ•„ํ„ฐ์— ์˜ํ•ด ์ƒ๋žต๋จ: ${riskResult.reason} (${riskResult.detail || ''})`); + return false; + } + + // 2. ์ƒํƒœ๋ณ„ ์ž‘์—… ๋ฐ ์ „์ด + if (event.type === 'BUY_SIGNAL') { + // ์ด๋ฏธ ์ง„ํ–‰ ์ค‘์ธ ํฌ์ง€์…˜์ด ์žˆ๋Š”์ง€ ํ™•์ธ (์ค‘๋ณต ์ฃผ๋ฌธ ๋ฐฉ์ง€) + const data = await positionsRepo.load(); + if (data.positions.some(p => p.market === event.market && (p.state === 'OPEN' || p.state === 'ENTRY_PENDING'))) { + Logger.warn(`[SKIP] ์ด๋ฏธ ํ•ด๋‹น ๋งˆ์ผ“์˜ ํฌ์ง€์…˜์ด ์กด์žฌํ•˜์—ฌ ์ƒ๋žตํ•จ: ${event.market}`); + return false; + } + + // ์ƒํƒœ ์ „์ด: FLAT -> ENTRY_PENDING + await positionsRepo.createEntryPending(event.market, event.meta?.strategy || 'unknown', riskResult.budgetKRW); + + // ์‹ค์ œ ์ฃผ๋ฌธ ์‹คํ–‰ (์‹œ์žฅ๊ฐ€ ๋งค์ˆ˜) + const orderResult = await this.orderService.placeMarketBuy(event.market, riskResult.budgetKRW); + Logger.info(`[DONE] ์‹œ์žฅ๊ฐ€ ๋งค์ˆ˜ ์ฃผ๋ฌธ ์™„๋ฃŒ: ${orderResult.uuid}`); + + // ์ƒํƒœ ์ „์ด: ENTRY_PENDING -> OPEN (์‹ค์ œ ์ฒด๊ฒฐ ํ™•์ธ์€ FillWatcher์—์„œ ๋ณด์™„) + await positionsRepo.updateToOpen(event.market, orderResult); + return true; + } + + if (event.type === 'TARGET_HIT' || event.type === 'STOPLOSS_HIT') { + // ์ƒํƒœ ์ „์ด: OPEN -> EXIT_PENDING + await positionsRepo.updateToExitPending(event.market, event.type); + + // ์‹ค์ œ ์ฃผ๋ฌธ ์‹คํ–‰ (์‹œ์žฅ๊ฐ€ ๋งค๋„) + const orderResult = await this.orderService.placeMarketSell(event.market, riskResult.volume); + Logger.info(`[DONE] ์‹œ์žฅ๊ฐ€ ๋งค๋„ ์ฃผ๋ฌธ ์™„๋ฃŒ: ${orderResult.uuid}`); + + // ์ƒํƒœ ์ „์ด: EXIT_PENDING -> CLOSED + await positionsRepo.updateToClosed(event.market, orderResult); + return true; + } + + return false; + } catch (err) { + Logger.error(`Trade Execution Error: ${err.message}`); + throw err; + } + } +} + +module.exports = TradeExecutor; diff --git a/skills/kuns9/trading-upbit-skill/scripts/execution/upbitClient.js b/skills/kuns9/trading-upbit-skill/scripts/execution/upbitClient.js new file mode 100644 index 0000000000..8ae0160ad9 --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/scripts/execution/upbitClient.js @@ -0,0 +1,102 @@ +const axios = require('axios'); +const jwt = require('jsonwebtoken'); +const { v4: uuidv4 } = require('uuid'); +const crypto = require('crypto'); +const querystring = require('querystring'); + +/** + * Upbit ํด๋ผ์ด์–ธํŠธ (upbitClient.js) + * - ์ธ์ฆ ํ—ค๋” ์ƒ์„ฑ + * - Rate Limit (429) ์ž๋™ ์žฌ์‹œ๋„ + * - ํ‘œ์ค€ ์—๋Ÿฌ ๋กœ๊น… + */ + +const Logger = { + info: (msg) => console.log(`[${new Date().toLocaleString()}] [UPBIT-CLIENT] ${msg}`), + warn: (msg) => console.warn(`[${new Date().toLocaleString()}] [UPBIT-WARN] ${msg}`), + error: (msg) => console.error(`[${new Date().toLocaleString()}] [UPBIT-ERR] ${msg}`), +}; + +class UpbitClient { + constructor(accessKey, secretKey) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.baseUrl = 'https://api.upbit.com/v1'; + + this.api = axios.create({ + baseURL: this.baseUrl, + timeout: 10000, + headers: { 'Content-Type': 'application/json' } // ๊ถŒ์žฅ๋˜๋Š” JSON ํ—ค๋” + }); + + this._setupInterceptors(); + } + + _createAuthHeader(queryParams = {}) { + const payload = { + access_key: this.accessKey, + nonce: uuidv4(), + }; + + const query = querystring.stringify(queryParams); + if (query) { + const hash = crypto.createHash('sha512'); + const queryHash = hash.update(query, 'utf-8').digest('hex'); + payload.query_hash = queryHash; + payload.query_hash_alg = 'SHA512'; + } + + const token = jwt.sign(payload, this.secretKey, { algorithm: 'HS512' }); + return `Bearer ${token}`; + } + + _setupInterceptors() { + this.api.interceptors.response.use( + (response) => response, + async (error) => { + const { config, response } = error; + if (response && response.status === 429) { + const remainingSec = parseFloat(response.headers['remaining-second']) || 1; + const waitTime = (remainingSec + 0.5) * 1000; + Logger.warn(`Rate Limit(429) ๋„๋‹ฌ. ${waitTime}ms ํ›„ ์žฌ์‹œ๋„...`); + await new Promise(resolve => setTimeout(resolve, waitTime)); + return this.api(config); + } + + const upbitError = response?.data?.error; + if (upbitError) { + Logger.error(`API Error [${response.status}] (${upbitError.name}): ${upbitError.message}`); + } else { + Logger.error(`Network/HTTP Error: ${error.message}`); + } + return Promise.reject(error); + } + ); + } + + async request(method, url, data = {}, params = {}) { + const isAuthRequest = url.startsWith('/orders') || url.startsWith('/accounts') || url.startsWith('/order'); + const headers = {}; + + if (isAuthRequest) { + // POST์˜ ๊ฒฝ์šฐ data๊ฐ€ query_hash ๋Œ€์ƒ, GET์˜ ๊ฒฝ์šฐ params๊ฐ€ ๋Œ€์ƒ + const authParams = method.toLowerCase() === 'get' ? params : data; + headers.Authorization = this._createAuthHeader(authParams); + } + + try { + const response = await this.api({ + method, + url, + data: method.toLowerCase() === 'get' ? undefined : data, + params, + headers + }); + return response.data; + } catch (err) { + throw err; + } + } +} + +module.exports = { UpbitClient, Logger }; diff --git a/skills/kuns9/trading-upbit-skill/scripts/indicators/indicators.js b/skills/kuns9/trading-upbit-skill/scripts/indicators/indicators.js new file mode 100644 index 0000000000..5edbc7a0f5 --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/scripts/indicators/indicators.js @@ -0,0 +1,233 @@ +#!/usr/bin/env node +/** + * ๊ธฐ์ˆ ์  ์ง€ํ‘œ ๊ณ„์‚ฐ ๋ชจ๋“ˆ (V2 - ์ตœ์ ํ™” ๋ฐ ํ‘œ์ค€ํ™” ๋ฒ„์ „) + */ + +/** + * ๋ฐ์ดํ„ฐ ์ •์ œ ์œ ํ‹ธ๋ฆฌํ‹ฐ + * @param {any[]} data - ์ž…๋ ฅ ๋ฐ์ดํ„ฐ + * @param {Object} options - { newestFirst: false } + */ +function sanitizePrices(data, options = {}) { + if (!Array.isArray(data)) return []; + + const { newestFirst = false } = options; + let prices = data.map(v => { + if (typeof v === 'object' && v !== null && 'close' in v) return Number(v.close); + return Number(v); + }); + + // NaN, Infinity ์ œ๊ฑฐ + prices = prices.filter(v => Number.isFinite(v)); + + if (newestFirst) { + prices.reverse(); + } + + return prices; +} + +/** + * ์บ”๋“ค ๋ฐ์ดํ„ฐ ์ •์ œ ์œ ํ‹ธ๋ฆฌํ‹ฐ (ATR ๋“ฑ์šฉ) + */ +function sanitizeCandles(data, options = {}) { + if (!Array.isArray(data)) return []; + const { newestFirst = false } = options; + + let candles = data.map(c => ({ + high: Number(c.high), + low: Number(c.low), + close: Number(c.close), + open: Number(c.open) + })).filter(c => + Number.isFinite(c.high) && + Number.isFinite(c.low) && + Number.isFinite(c.close) + ); + + if (newestFirst) { + candles.reverse(); + } + return candles; +} + +/** + * RSI (Relative Strength Index) + */ +function calculateRSI(data, period = 14, options = {}) { + const prices = sanitizePrices(data, options); + if (prices.length < period + 1) return { value: null }; + + let gains = 0; + let losses = 0; + + for (let i = 1; i <= period; i++) { + const change = prices[i] - prices[i - 1]; + if (change > 0) gains += change; + else losses -= change; + } + + let avgGain = gains / period; + let avgLoss = losses / period; + + for (let i = period + 1; i < prices.length; i++) { + const change = prices[i] - prices[i - 1]; + if (change > 0) { + avgGain = (avgGain * (period - 1) + change) / period; + avgLoss = (avgLoss * (period - 1)) / period; + } else { + avgGain = (avgGain * (period - 1)) / period; + avgLoss = (avgLoss * (period - 1) - change) / period; + } + } + + if (avgLoss === 0) return { value: 100, meta: { avgGain, avgLoss } }; + if (avgGain === 0) return { value: 0, meta: { avgGain, avgLoss } }; + + const rs = avgGain / avgLoss; + const rsi = 100 - (100 / (1 + rs)); + return { value: rsi, meta: { avgGain, avgLoss } }; +} + +/** + * EMA (์ง€์ˆ˜์ด๋™ํ‰๊ท ) - O(N) + */ +function calculateEMA(data, period, options = {}) { + const prices = sanitizePrices(data, options); + const { returnSeries = false } = options; + if (prices.length < period) return returnSeries ? [] : { value: null }; + + const multiplier = 2 / (period + 1); + let ema = prices.slice(0, period).reduce((a, b) => a + b, 0) / period; + const series = [ema]; + + for (let i = period; i < prices.length; i++) { + ema = (prices[i] - ema) * multiplier + ema; + if (returnSeries) series.push(ema); + } + + return returnSeries ? series : { value: ema }; +} + +/** + * MACD (Moving Average Convergence Divergence) - O(N) + */ +function calculateMACD(data, fastPeriod = 12, slowPeriod = 26, signalPeriod = 9, options = {}) { + const prices = sanitizePrices(data, options); + if (prices.length < slowPeriod + signalPeriod) return { value: null }; + + const fastEMAs = calculateEMA(prices, fastPeriod, { returnSeries: true }); + const slowEMAs = calculateEMA(prices, slowPeriod, { returnSeries: true }); + + const diff = slowPeriod - fastPeriod; + const macdLine = []; + for (let i = 0; i < slowEMAs.length; i++) { + macdLine.push(fastEMAs[i + diff] - slowEMAs[i]); + } + + const signalLineSeries = calculateEMA(macdLine, signalPeriod, { returnSeries: true }); + const currentMACD = macdLine[macdLine.length - 1]; + const currentSignal = signalLineSeries[signalLineSeries.length - 1]; + + return { + value: currentMACD, + signal: currentSignal, + histogram: currentMACD - currentSignal, + meta: { fastPeriod, slowPeriod, signalPeriod } + }; +} + +/** + * Bollinger Bands + */ +function calculateBollingerBands(data, period = 20, multiplier = 2, options = {}) { + const prices = sanitizePrices(data, options); + if (prices.length < period) return { value: null }; + + const slice = prices.slice(-period); + const ma = slice.reduce((a, b) => a + b, 0) / period; + const squaredDiffs = slice.map(p => Math.pow(p - ma, 2)); + const variance = squaredDiffs.reduce((a, b) => a + b, 0) / period; + const stdDev = Math.sqrt(variance); + + if (stdDev === 0) { + return { value: ma, upper: ma, lower: ma, meta: { stdDev: 0 } }; + } + + return { + value: ma, + upper: ma + (multiplier * stdDev), + lower: ma - (multiplier * stdDev), + meta: { stdDev, period, multiplier } + }; +} + +/** + * ATR (Average True Range) + */ +function calculateATR(data, period = 14, options = {}) { + const candles = sanitizeCandles(data, options); + if (candles.length < period + 1) return { value: null }; + + const trs = []; + for (let i = 1; i < candles.length; i++) { + const tr = Math.max( + candles[i].high - candles[i].low, + Math.abs(candles[i].high - candles[i - 1].close), + Math.abs(candles[i].low - candles[i - 1].close) + ); + trs.push(tr); + } + + let atr = trs.slice(0, period).reduce((a, b) => a + b, 0) / period; + for (let i = period; i < trs.length; i++) { + atr = (atr * (period - 1) + trs[i]) / period; + } + + return { value: atr, meta: { period } }; +} + +module.exports = { + sanitizePrices, + sanitizeCandles, + calculateRSI, + calculateEMA, + calculateMACD, + calculateBollingerBands, + calculateATR +}; + +// CLI ๋ฐ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ +if (require.main === module) { + const args = process.argv.slice(2); + const isJson = args.includes('--json'); + const newestFirst = args.includes('--newest-first'); + + const testPrices = [ + 44, 44.5, 43.5, 44.5, 44, 43.5, 44, 44.5, 44, 43.5, + 44.5, 45, 45.5, 46, 45.5, 46, 46.5, 47, 46.5, 47, + 47.5, 48, 48.5, 49, 48.5, 48, 47.5, 47, 46.5, 46, + 45.5, 45, 44.5, 44, 43.5, 43, 42.5, 42, 41.5, 41 + ]; + + const testCandles = testPrices.map(p => ({ high: p + 1, low: p - 1, close: p })); + + const results = { + rsi: calculateRSI(testPrices, 14, { newestFirst }), + macd: calculateMACD(testPrices, 12, 26, 9, { newestFirst }), + atr: calculateATR(testCandles, 14, { newestFirst }), + bb: calculateBollingerBands(testPrices, 20, 2, { newestFirst }) + }; + + if (isJson) { + console.log(JSON.stringify(results, null, 2)); + } else { + console.log('=== Indicators V2 Unit Tests ==='); + console.log('๋ฐ์ดํ„ฐ ๋ฐฉํ–ฅ:', newestFirst ? '์ตœ์‹  -> ๊ณผ๊ฑฐ' : '๊ณผ๊ฑฐ -> ์ตœ์‹ '); + console.log('RSI(14):', results.rsi.value?.toFixed(2)); + console.log('MACD Line:', results.macd.value?.toFixed(2)); + console.log('MACD Signal:', results.macd.signal?.toFixed(2)); + console.log('ATR(14):', results.atr.value?.toFixed(2)); + console.log('BB Upper:', results.bb.upper?.toFixed(2)); + } +} diff --git a/skills/kuns9/trading-upbit-skill/scripts/risk/riskManager.js b/skills/kuns9/trading-upbit-skill/scripts/risk/riskManager.js new file mode 100644 index 0000000000..f16ddf0bc7 --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/scripts/risk/riskManager.js @@ -0,0 +1,63 @@ +/** + * ๋ฆฌ์Šคํฌ ๊ด€๋ฆฌ ๋ชจ๋“ˆ (riskManager.js) + * - ์ž”๊ณ  ํ™•์ธ ๋ฐ ์ฃผ๋ฌธ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํŒ๋‹จ + * - ์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก(min_total) ํ•„ํ„ฐ + * - ์‚ฌ์ด์ง• ์ •์ฑ… + */ + +const { Logger } = require('../execution/upbitClient'); + +class RiskManager { + /** + * ์ฃผ๋ฌธ ์‹คํ–‰ ์ „ ๋ฆฌ์Šคํฌ ๊ฒ€์ฆ + * @param {Object} upbitClient - UpbitClient ์ธ์Šคํ„ด์Šค + * @param {Object} event - ์ฒ˜๋ฆฌํ•˜๋ ค๋Š” ์ด๋ฒคํŠธ + */ + async evaluate(upbitClient, event) { + try { + // 1. ์ฃผ๋ฌธ ๊ฐ€๋Šฅ ์ •๋ณด ์กฐํšŒ (orders/chance) + const chance = await upbitClient.request('GET', '/orders/chance', {}, { market: event.market }); + + const bidFee = parseFloat(chance.bid_fee); + const askFee = parseFloat(chance.ask_fee); + const krwBalance = parseFloat(chance.bid_account.balance); + const minTotal = parseFloat(chance.market.bid.min_total); // ์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก (KRW) + + if (event.type === 'BUY_SIGNAL') { + const budget = event.budgetKRW || 10000; // ๊ธฐ๋ณธ 1๋งŒ์› ๋˜๋Š” ์„ค์ •๊ฐ’ + + // ์ž”๊ณ  ๋ถ€์กฑ ํ™•์ธ + if (krwBalance < budget) { + return { allow: false, reason: 'INSUFFICIENT_BALANCE', detail: `์ž”๊ณ ๋ถ€์กฑ: ${krwBalance.toLocaleString()} KRW` }; + } + + // ์ตœ์†Œ ์ฃผ๋ฌธ ๊ธˆ์•ก ๋ฏธ๋‹ฌ ํ™•์ธ + if (budget < minTotal) { + return { allow: false, reason: 'UNDER_MIN_TOTAL', detail: `์ตœ์†Œ์ฃผ๋ฌธ๊ธˆ์•ก ๋ฏธ๋‹ฌ: ${budget} < ${minTotal}` }; + } + + return { allow: true, budgetKRW: budget, fee: bidFee }; + } + + if (event.type === 'TARGET_HIT' || event.type === 'STOPLOSS_HIT') { + // ๋งค๋„์˜ ๊ฒฝ์šฐ ๋ณด์œ  ์ˆ˜๋Ÿ‰ ํ™•์ธ (accounts) + const accounts = await upbitClient.request('GET', '/accounts'); + const currency = event.market.split('-')[1]; + const asset = accounts.find(a => a.currency === currency); + + if (!asset || parseFloat(asset.balance) <= 0) { + return { allow: false, reason: 'NO_ASSET_TO_SELL' }; + } + + return { allow: true, volume: asset.balance, fee: askFee }; + } + + return { allow: false, reason: 'UNKNOWN_EVENT_TYPE' }; + } catch (err) { + Logger.error(`Risk Evaluation Failed: ${err.message}`); + return { allow: false, reason: 'ERROR', detail: err.message }; + } + } +} + +module.exports = new RiskManager(); diff --git a/skills/kuns9/trading-upbit-skill/scripts/state/positionsRepo.js b/skills/kuns9/trading-upbit-skill/scripts/state/positionsRepo.js new file mode 100644 index 0000000000..a4949c6dc6 --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/scripts/state/positionsRepo.js @@ -0,0 +1,89 @@ +const fs = require('fs').promises; +const path = require('path'); + +/** + * ํฌ์ง€์…˜ ์ €์žฅ์†Œ (positionsRepo.js) + * - ์ƒํƒœ ๋จธ์‹  ๊ธฐ๋ฐ˜ ํฌ์ง€์…˜ ๊ด€๋ฆฌ + * - FLAT -> ENTRY_PENDING -> OPEN -> EXIT_PENDING -> CLOSED + */ + +const POSITIONS_FILE = path.join(process.cwd(), 'resources', 'positions.json'); + +class PositionsRepo { + async load() { + try { + const data = await fs.readFile(POSITIONS_FILE, 'utf8'); + return JSON.parse(data); + } catch (err) { + return { positions: [] }; + } + } + + async save(data) { + await fs.writeFile(POSITIONS_FILE, JSON.stringify(data, null, 2)); + } + + /** + * ํฌ์ง€์…˜ ์ƒ์„ฑ (FLAT -> ENTRY_PENDING) + */ + async createEntryPending(market, strategy, budget) { + const data = await this.load(); + const newPos = { + id: `pos_${Date.now()}`, + market, + state: 'ENTRY_PENDING', + entry: { + budgetKRW: budget, + createdAt: new Date().toISOString() + }, + meta: { strategy } + }; + data.positions.push(newPos); + await this.save(data); + return newPos; + } + + /** + * ์ง„์ž… ์™„๋ฃŒ ์—…๋ฐ์ดํŠธ (ENTRY_PENDING -> OPEN) + */ + async updateToOpen(market, orderResult) { + const data = await this.load(); + const pos = data.positions.find(p => p.market === market && p.state === 'ENTRY_PENDING'); + if (pos) { + pos.state = 'OPEN'; + pos.entry.orderUuid = orderResult.uuid; + pos.entry.avgFillPrice = parseFloat(orderResult.price || 0); + pos.entry.openedAt = new Date().toISOString(); + await this.save(data); + } + } + + /** + * ์ฒญ์‚ฐ ๋Œ€๊ธฐ ์—…๋ฐ์ดํŠธ (OPEN -> EXIT_PENDING) + */ + async updateToExitPending(market, reason) { + const data = await this.load(); + const pos = data.positions.find(p => p.market === market && p.state === 'OPEN'); + if (pos) { + pos.state = 'EXIT_PENDING'; + pos.exit = { reason, triggeredAt: new Date().toISOString() }; + await this.save(data); + } + } + + /** + * ํฌ์ง€์…˜ ์ข…๋ฃŒ (EXIT_PENDING -> CLOSED) + */ + async updateToClosed(market, orderResult) { + const data = await this.load(); + const pos = data.positions.find(p => p.market === market && p.state === 'EXIT_PENDING'); + if (pos) { + pos.state = 'CLOSED'; + pos.exit.orderUuid = orderResult.uuid; + pos.exit.closedAt = new Date().toISOString(); + await this.save(data); + } + } +} + +module.exports = new PositionsRepo(); diff --git a/skills/kuns9/trading-upbit-skill/scripts/strategies/strategies.js b/skills/kuns9/trading-upbit-skill/scripts/strategies/strategies.js new file mode 100644 index 0000000000..807435e9aa --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/scripts/strategies/strategies.js @@ -0,0 +1,23 @@ +/** + * ๋งค๋งค ์ „๋žต ๋ชจ๋“ˆ + */ + +/** + * ๋ณ€๋™์„ฑ ๋ŒํŒŒ ์ „๋žต (Volatility Breakout Strategy) + * @param {Object} currentCandle - ํ˜„์žฌ๊ฐ€ ํฌํ•จ๋œ ์บ”๋“ค { open, high, low, close } + * @param {number} prevRange - ์ „์ผ ๊ณ ๊ฐ€ - ์ „์ผ ์ €๊ฐ€ + * @param {number} k - ๋ณ€๋™์„ฑ ๊ณ„์ˆ˜ (๊ธฐ๋ณธ 0.5) + */ +function volatilityBreakout(currentCandle, prevRange, k = 0.5) { + const targetPrice = currentCandle.open + (prevRange * k); + return { + targetPrice, + signal: currentCandle.close > targetPrice ? 'BUY' : 'HOLD', + isBreakout: currentCandle.high > targetPrice, + meta: { prevRange, k } + }; +} + +module.exports = { + volatilityBreakout +}; diff --git a/skills/kuns9/trading-upbit-skill/scripts/workers/eventWorker.js b/skills/kuns9/trading-upbit-skill/scripts/workers/eventWorker.js new file mode 100644 index 0000000000..e76334561d --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/scripts/workers/eventWorker.js @@ -0,0 +1,57 @@ +/** + * ์ด๋ฒคํŠธ ์›Œ์ปค (eventWorker.js) + * - events.json ๊ฐ์‹œ ๋ฐ ์ฒ˜๋ฆฌ + * - TradeExecutor ํ˜ธ์ถœ + */ + +const fs = require('fs').promises; +const path = require('path'); +const { UpbitClient, Logger } = require('../execution/upbitClient'); +const OrderService = require('../execution/orderService'); +const TradeExecutor = require('../execution/tradeExecutor'); +require('dotenv').config({ path: path.join(process.cwd(), '.env') }); + +const ACCESS_KEY = process.env.UPBIT_OPEN_API_ACCESS_KEY; +const SECRET_KEY = process.env.UPBIT_OPEN_API_SECRET_KEY; +const EVENTS_FILE = path.join(process.cwd(), 'resources', 'events.json'); + +const client = new UpbitClient(ACCESS_KEY, SECRET_KEY); +const orderService = new OrderService(client); +const executor = new TradeExecutor(orderService); + +async function processEvents() { + try { + const data = await fs.readFile(EVENTS_FILE, 'utf8').catch(() => '[]'); + let events = JSON.parse(data); + const pendingEvents = events.filter(e => !e.processed); + + if (pendingEvents.length === 0) return; + + for (const event of pendingEvents) { + try { + const success = await executor.execute(event); + if (success) { + event.processed = true; + event.processedAt = new Date().toISOString(); + } else { + // ๋ฆฌ์Šคํฌ ๋“ฑ์— ์˜ํ•ด ๊ฑฐ๋ถ€๋œ ๊ฒฝ์šฐ๋„ ์ฒ˜๋ฆฌ ์™„๋ฃŒ๋กœ ํ‘œ์‹œ (์ค‘๋ณต ์‹คํ–‰ ๋ฐฉ์ง€) + event.processed = true; + } + } catch (err) { + Logger.error(`Event Process Error (${event.id}): ${err.message}`); + } + } + + await fs.writeFile(EVENTS_FILE, JSON.stringify(events, null, 2)); + } catch (err) { + Logger.error(`Worker Loop Error: ${err.message}`); + } +} + +function run() { + processEvents(); + setTimeout(run, 5000); // 5์ดˆ๋งˆ๋‹ค ์ฒดํฌ +} + +Logger.info('โš™๏ธ Event Worker Started'); +run(); diff --git a/skills/kuns9/trading-upbit-skill/scripts/workers/monitor.js b/skills/kuns9/trading-upbit-skill/scripts/workers/monitor.js new file mode 100644 index 0000000000..d8d81b1792 --- /dev/null +++ b/skills/kuns9/trading-upbit-skill/scripts/workers/monitor.js @@ -0,0 +1,132 @@ +/** + * ์ƒˆ ์•„ํ‚คํ…์ฒ˜ ๊ธฐ๋ฐ˜ ๋ชจ๋‹ˆํ„ฐ (monitor.js) + * - ํฌ์ง€์…˜ ์‹ค์‹œ๊ฐ„ ๊ฐ์‹œ (State-Aware) + * - ์ฃผ๊ธฐ์  ์‹œ์žฅ ์Šค์บ” + */ + +const { UpbitClient, Logger } = require('../execution/upbitClient'); +const marketData = require('../data/marketData'); +const positionsRepo = require('../state/positionsRepo'); +const strategies = require('../strategies/strategies'); +const fs = require('fs').promises; +const path = require('path'); +require('dotenv').config({ path: path.join(process.cwd(), '.env') }); + +const ACCESS_KEY = process.env.UPBIT_OPEN_API_ACCESS_KEY; +const SECRET_KEY = process.env.UPBIT_OPEN_API_SECRET_KEY; +const EVENTS_FILE = path.join(process.cwd(), 'resources', 'events.json'); + +const CONFIG = { + priceCheckInterval: parseInt(process.env.PRICE_CHECK_INTERVAL) || 10000, + scanInterval: 60000 * 5, + watchlist: ['KRW-BTC', 'KRW-ETH', 'KRW-SOL'], + targetProfit: parseFloat(process.env.TARGET_PROFIT) || 0.05, + stopLoss: parseFloat(process.env.STOP_LOSS) || -0.05, +}; + +const client = new UpbitClient(ACCESS_KEY, SECRET_KEY); +let lastScanTime = 0; + +async function addEvent(event) { + try { + const data = await fs.readFile(EVENTS_FILE, 'utf8').catch(() => '[]'); + const events = JSON.parse(data); + const dedupeKey = `${event.type}:${event.market}`; + + // 1์‹œ๊ฐ„ ๋‚ด ๋™์ผ ๋งˆ์ผ“ ๋™์ผ ํƒ€์ž… ์ด๋ฒคํŠธ ์ค‘๋ณต ๋ฐฉ์ง€ + const hourAgo = Date.now() - 3600000; + if (events.some(e => e.dedupeKey === dedupeKey && new Date(e.createdAt).getTime() > hourAgo && !e.processed)) { + return; + } + + events.push({ + id: `evt_${Date.now()}`, + ...event, + dedupeKey, + processed: false, + createdAt: new Date().toISOString() + }); + await fs.writeFile(EVENTS_FILE, JSON.stringify(events, null, 2)); + Logger.warn(`๐Ÿ“ข ์ด๋ฒคํŠธ ๋“ฑ๋ก: ${event.type} - ${event.market}`); + } catch (err) { + Logger.error(`Event Save Error: ${err.message}`); + } +} + +async function scanMarkets() { + Logger.info('๐Ÿ” ์‹œ์žฅ ์Šค์บ” ์‹œ์ž‘...'); + try { + const markets = await marketData.getMarkets(true); + for (const m of markets) { + // ์ „๋žต ์ฒดํฌ ๋กœ์ง (Day + 60m ํ™•์ธ) + // (๊ธฐ์กด monitor.js ๋กœ์ง ์žฌํ™œ์šฉ) + const dayCandles = await marketData.getCandles('days', m.market, 2); + if (dayCandles.length < 2) continue; + + const current = { high: dayCandles[0].high_price, close: dayCandles[0].trade_price }; + const range = dayCandles[1].high_price - dayCandles[1].low_price; + + const result = strategies.volatilityBreakout(current, range, 0.5); + if (result.signal === 'BUY') { + const sub = await marketData.getCandles('minutes', m.market, 1, 60); + if (sub[0].trade_price > sub[0].opening_price) { + await addEvent({ + type: 'BUY_SIGNAL', + market: m.market, + payload: { price: current.close }, + meta: { strategy: 'VolatilityBreakout' } + }); + } + } + } + Logger.info('์‹œ์žฅ ์Šค์บ” ์™„๋ฃŒ'); + } catch (err) { + Logger.error(`Scan Error: ${err.message}`); + } +} + +async function monitor() { + try { + const data = await positionsRepo.load(); + const positions = (data.positions || []).filter(p => p.state === 'OPEN'); + + // ์‹œ์„ธ ์‹ค์‹œ๊ฐ„ ์ถœ๋ ฅ + const combined = [...new Set([...positions.map(p => p.market), ...CONFIG.watchlist])]; + if (combined.length > 0) { + const tickers = await marketData.getTickers(combined); + constไปทๆ ผMap = {}; + tickers.forEach(t => ไปทๆ ผMap[t.market] = t.trade_price); + + Logger.info(`๐Ÿ‘€ [Watch] ${CONFIG.watchlist.map(m => `${m.split('-')[1]}: ${ไปทๆ ผMap[m]?.toLocaleString()}`).join(' | ')}`); + + for (const pos of positions) { + const current = ไปทๆ ผMap[pos.market]; + if (!current) continue; + const entry = pos.entry.avgFillPrice; + const pnl = (current - entry) / entry; + Logger.info(`๐Ÿ’ฐ [Hold] ${pos.market}: ${current.toLocaleString()}์› (${(pnl * 100).toFixed(2)}%)`); + + if (pnl >= CONFIG.targetProfit) { + await addEvent({ type: 'TARGET_HIT', market: pos.market }); + } else if (pnl <= CONFIG.stopLoss) { + await addEvent({ type: 'STOPLOSS_HIT', market: pos.market }); + } + } + } + + if (Date.now() - lastScanTime > CONFIG.scanInterval) { + await scanMarkets(); + lastScanTime = Date.now(); + } + } catch (err) { + Logger.error(`Monitor Error: ${err.message}`); + } +} + +function run() { + monitor(); + setTimeout(run, CONFIG.priceCheckInterval); +} + +Logger.info('๐Ÿš€ Monitor Engine Started'); +run();