From df4d7ef799a07fe90edb8f34e914f5fc90d19efe Mon Sep 17 00:00:00 2001 From: fray-cloud Date: Fri, 1 May 2026 01:59:38 +0900 Subject: [PATCH 1/2] chore(claude): add Karpathy coding behavior guidelines to CLAUDE.md Adds a four-principle "Coding behavior" section (Think before coding, Simplicity first, Surgical changes, Goal-driven execution) above the existing Skill routing rules. Surfacing these rules in CLAUDE.md keeps implicit guidance explicit and actionable across future sessions. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 2567c0b..5c1310f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,34 @@ +## Coding behavior + +이 저장소의 모든 작업에 적용되는 행동 규칙. 속도보다 신중함에 가중을 둠. +사소한 작업은 판단에 맡김. + +### 1. Think before coding + +- 가정은 명시적으로 드러내라. 불확실하면 물어라. +- 해석이 여러 개면 모두 제시하라. 침묵하고 고르지 말 것. +- 더 단순한 접근이 보이면 말하라. 필요하면 사용자 의도에 반대하라. + +### 2. Simplicity first + +- 문제를 푸는 최소한의 코드만. +- 요청하지 않은 기능/추상화/에러 처리 금지. +- 200줄을 50줄로 줄일 수 있으면 다시 써라. + +### 3. Surgical changes + +- 작업이 요구하는 것만 건드려라. +- 인접한 코드/주석/포맷을 "개선"하지 마라. +- 기존 스타일을 따라라. 무관한 dead code는 언급만, 삭제는 하지 마라. +- 본인 변경이 만든 고아 import/변수만 제거하라. + +### 4. Goal-driven execution + +- 모든 작업을 검증 가능한 성공 기준으로 번역한 후 코딩 시작. + - "validation 추가" → "잘못된 입력에 대한 테스트가 통과한다." + - "버그 수정" → "재현 테스트가 통과한다." + - "X 리팩터" → "리팩터 전후 테스트가 모두 통과한다." + ## Skill routing When the user's request matches an available skill, ALWAYS invoke it using the Skill From d2ef4121e7ab5de90e3a416d94ef0601cef9e33e Mon Sep 17 00:00:00 2001 From: fray-cloud Date: Fri, 1 May 2026 02:00:03 +0900 Subject: [PATCH 2/2] feat: drop Upbit/Bybit/strategies/flows/backtests, reduce to Binance-only foundation Foundation cleanup PR before introducing Binance Futures + Claude CLI driven trading. Removes everything not needed for the new direction. What's removed: - Upbit and Bybit exchange adapters (REST + WS + tests, ~1300 lines) - worker-service strategies/, backtesting/, backtests/, flows/, paper-engine.service.ts - api-server strategies/, flows/, e2e/strategies tests - web app/strategies, app/flows, app/orders, app/backtests routes + components - web hooks for strategies/flows/orders/backtests, lib/indicators - Strategy factory + indicator constants from test-utils and lib - Strategy/Flow/Backtest/StrategyLog/BacktestTrace Prisma models - StrategySignalEvent/BacktestRequested/Completed Kafka events and topics What's reshaped: - ExchangeId union narrowed to 'binance'; all DTOs, factories, REST_ADAPTERS, EXCHANGES constants updated accordingly - Activity service drops strategy_signal/strategy_order/risk_blocked types - Notification settings/event types reduced to order_filled/order_failed - Markets service/gateway drops strategy:signal and backtest:completed listeners - Prisma migrations folder cleared and a single fresh init migration is applied by `prisma migrate dev` against the empty volume What's added (infra): - Docker Compose data volumes converted to named volumes (postgres_data, redis_data, kafka_data, zookeeper_data/log) to bypass WSL2 bind-mount permission collisions - API/worker/nginx logs moved to named volumes (stdout logging in services) - Web container's anonymous /app/node_modules volume removed so pnpm symlinks resolve correctly inside the container - Postgres logging_collector and Redis --logfile flags removed (use stdout) Verified end-to-end: - pnpm build green across all 9 workspace packages - docker compose up: postgres/redis/kafka healthy, api-server /api/health 200, worker connects to Kafka and joins consumer group, web renders /markets - Prisma init migration generated and applied against fresh volume Plan: PR2 will bring Binance Futures (fapi.binance.com) adapter + futures fields on OrderRequest/Result/Order; PR3 will add Claude CLI driven LLM trade flow + per-user OAuth token storage. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api-server/docker-compose.dev.yml | 5 +- apps/api-server/e2e/orders.e2e-test.ts | 2 +- apps/api-server/e2e/strategies.e2e-test.ts | 97 --- .../src/activity/activity.service.ts | 40 +- apps/api-server/src/app.module.ts | 4 - .../create-exchange-key.handler.test.ts | 8 +- .../commands/create-exchange-key.handler.ts | 4 +- .../dto/create-exchange-key.dto.ts | 4 +- .../queries/get-balances.handler.ts | 4 +- .../queries/get-exchange-keys.handler.test.ts | 2 +- .../queries/get-markets.handler.ts | 4 +- .../queries/get-open-orders.handler.ts | 4 +- .../src/flows/commands/create-flow.command.ts | 8 - .../src/flows/commands/create-flow.handler.ts | 79 --- .../src/flows/commands/delete-flow.command.ts | 6 - .../src/flows/commands/delete-flow.handler.ts | 25 - apps/api-server/src/flows/commands/index.ts | 19 - .../commands/request-backtest.command.ts | 9 - .../commands/request-backtest.handler.ts | 74 -- .../src/flows/commands/toggle-flow.command.ts | 6 - .../src/flows/commands/toggle-flow.handler.ts | 28 - .../src/flows/commands/update-flow.command.ts | 9 - .../src/flows/commands/update-flow.handler.ts | 85 --- .../src/flows/dto/backtest-request.dto.ts | 12 - .../src/flows/dto/create-flow.dto.ts | 63 -- .../src/flows/dto/flow-response.dto.ts | 93 --- .../src/flows/dto/update-flow.dto.ts | 57 -- .../src/flows/flows-kafka.consumer.ts | 64 -- .../src/flows/flows-kafka.producer.ts | 41 -- apps/api-server/src/flows/flows.controller.ts | 143 ---- apps/api-server/src/flows/flows.module.ts | 15 - .../queries/get-backtest-trace.handler.ts | 47 -- .../flows/queries/get-backtest-trace.query.ts | 11 - .../flows/queries/get-backtests.handler.ts | 22 - .../src/flows/queries/get-backtests.query.ts | 6 - .../src/flows/queries/get-flow.handler.ts | 30 - .../src/flows/queries/get-flow.query.ts | 6 - .../src/flows/queries/get-flows.handler.ts | 27 - .../src/flows/queries/get-flows.query.ts | 3 - apps/api-server/src/flows/queries/index.ts | 16 - .../src/markets/markets.controller.ts | 10 +- .../api-server/src/markets/markets.gateway.ts | 22 - apps/api-server/src/markets/markets.module.ts | 3 +- .../api-server/src/markets/markets.service.ts | 56 +- .../notifications/notifications.service.ts | 4 +- .../orders/commands/cancel-order.handler.ts | 6 +- .../commands/create-order.handler.test.ts | 10 +- .../src/orders/dto/create-order.dto.ts | 4 +- .../orders/queries/get-orders.handler.test.ts | 6 +- .../src/portfolio/portfolio.service.ts | 19 +- .../commands/create-strategy.command.ts | 8 - .../commands/create-strategy.handler.test.ts | 73 -- .../commands/create-strategy.handler.ts | 46 -- .../commands/delete-strategy.command.ts | 6 - .../commands/delete-strategy.handler.test.ts | 33 - .../commands/delete-strategy.handler.ts | 21 - .../src/strategies/commands/index.ts | 19 - .../commands/reorder-strategies.command.ts | 8 - .../reorder-strategies.handler.test.ts | 62 -- .../commands/reorder-strategies.handler.ts | 35 - .../commands/toggle-strategy.command.ts | 6 - .../commands/toggle-strategy.handler.test.ts | 44 -- .../commands/toggle-strategy.handler.ts | 28 - .../commands/update-strategy.command.ts | 9 - .../commands/update-strategy.handler.test.ts | 37 - .../commands/update-strategy.handler.ts | 40 -- .../src/strategies/dto/create-strategy.dto.ts | 77 --- .../strategies/dto/reorder-strategies.dto.ts | 22 - .../strategies/dto/strategy-response.dto.ts | 121 ---- .../src/strategies/dto/update-strategy.dto.ts | 69 -- .../queries/get-strategies.handler.test.ts | 28 - .../queries/get-strategies.handler.ts | 15 - .../queries/get-strategies.query.ts | 3 - .../queries/get-strategy-logs.handler.test.ts | 50 -- .../queries/get-strategy-logs.handler.ts | 37 - .../queries/get-strategy-logs.query.ts | 10 - .../get-strategy-performance.handler.test.ts | 69 -- .../get-strategy-performance.handler.ts | 201 ------ .../queries/get-strategy-performance.query.ts | 6 - .../get-strategy-signals.handler.test.ts | 43 -- .../queries/get-strategy-signals.handler.ts | 45 -- .../queries/get-strategy-signals.query.ts | 6 - .../queries/get-strategy.handler.test.ts | 31 - .../queries/get-strategy.handler.ts | 17 - .../strategies/queries/get-strategy.query.ts | 6 - .../src/strategies/queries/index.ts | 19 - .../src/strategies/strategies.controller.ts | 229 ------- .../src/strategies/strategies.module.ts | 12 - apps/web/docker-compose.dev.yml | 1 - apps/web/src/app/activity/page.tsx | 33 +- apps/web/src/app/dashboard/page.tsx | 287 +------- apps/web/src/app/flows/[id]/page.tsx | 73 -- apps/web/src/app/flows/page.tsx | 121 ---- .../app/markets/[exchange]/[symbol]/page.tsx | 7 +- apps/web/src/app/markets/page.tsx | 9 +- apps/web/src/app/notifications/page.tsx | 10 - apps/web/src/app/orders/page.tsx | 63 -- apps/web/src/app/settings/page.tsx | 12 +- apps/web/src/app/strategies/[id]/page.tsx | 110 --- apps/web/src/app/strategies/page.tsx | 183 ----- apps/web/src/components/candle-chart.tsx | 309 +-------- apps/web/src/components/flows/flow-canvas.tsx | 196 ------ apps/web/src/components/flows/flow-card.tsx | 92 --- .../web/src/components/flows/flow-toolbar.tsx | 112 --- .../src/components/flows/node-help-data.ts | 91 --- .../src/components/flows/node-inspector.tsx | 288 -------- .../web/src/components/flows/node-palette.tsx | 86 --- .../src/components/flows/nodes/base-node.tsx | 444 ------------ .../flows/template-picker-modal.tsx | 127 ---- .../src/components/flows/timeline-slider.tsx | 100 --- .../src/components/icons/exchange-icon.tsx | 2 - .../components/markets/ticker-card-list.tsx | 166 +---- apps/web/src/components/mobile-tab-bar.tsx | 4 - apps/web/src/components/nav-bar.tsx | 24 - apps/web/src/components/notification-feed.tsx | 13 +- apps/web/src/components/orders/live-price.tsx | 40 -- apps/web/src/components/orders/order-form.tsx | 300 -------- .../src/components/orders/orders-table.tsx | 361 ---------- .../components/orders/quick-order-panel.tsx | 348 ---------- .../web/src/components/orders/symbol-card.tsx | 47 -- .../strategies/create-strategy-form.tsx | 453 ------------- .../strategies/easy-strategy-wizard.tsx | 345 ---------- .../components/strategies/execution-logs.tsx | 181 ----- .../strategies/performance-card.tsx | 69 -- .../strategies/strategy-card.test.tsx | 82 --- .../components/strategies/strategy-card.tsx | 162 ----- .../components/strategies/strategy-info.tsx | 354 ---------- apps/web/src/components/strategy-chart.tsx | 417 ------------ apps/web/src/components/ticker-table.tsx | 21 +- apps/web/src/hooks/use-backtest-ws.ts | 76 --- apps/web/src/hooks/use-backtest.ts | 24 - apps/web/src/hooks/use-compare-chart.ts | 87 --- apps/web/src/hooks/use-flows.ts | 19 - apps/web/src/hooks/use-order-form.ts | 38 -- apps/web/src/hooks/use-order-updates.ts | 17 - apps/web/src/hooks/use-orders.ts | 14 - apps/web/src/hooks/use-strategies.ts | 11 - apps/web/src/hooks/use-strategy-form.ts | 27 - .../web/src/hooks/use-strategy-performance.ts | 12 - apps/web/src/hooks/use-strategy-runtime.ts | 46 -- apps/web/src/hooks/use-strategy-signals.ts | 13 - apps/web/src/lib/api-client.ts | 306 +-------- apps/web/src/lib/constants.ts | 45 +- apps/web/src/lib/demo-ws.ts | 111 +-- apps/web/src/lib/indicators.ts | 135 ---- apps/web/src/mocks/data/activity.ts | 85 +-- apps/web/src/mocks/data/flows.ts | 90 --- apps/web/src/mocks/data/orders.ts | 164 ----- apps/web/src/mocks/data/portfolio.ts | 8 +- apps/web/src/mocks/data/strategies.ts | 134 ---- apps/web/src/mocks/handlers.ts | 66 +- apps/web/src/stores/use-flow-store.ts | 190 ------ .../src/stores/use-notification-feed-store.ts | 1 - .../stores/use-order-updates-store.test.ts | 86 --- .../web/src/stores/use-order-updates-store.ts | 6 +- apps/web/src/stores/use-tickers-store.test.ts | 8 +- apps/worker-service/docker-compose.dev.yml | 5 +- apps/worker-service/src/app.module.ts | 8 - .../src/backtesting/backtest-engine.ts | 118 ---- .../src/backtesting/backtesting.module.ts | 20 - .../src/backtesting/backtesting.service.ts | 198 ------ .../src/backtesting/backtesting.types.ts | 144 ---- .../src/backtesting/data.service.ts | 128 ---- .../src/backtesting/metrics.calculator.ts | 160 ----- .../src/backtesting/monte-carlo.service.ts | 110 --- .../src/backtesting/optimizer.service.ts | 108 --- .../src/backtesting/walk-forward.service.ts | 148 ---- .../src/backtests/backtests.module.ts | 8 - .../src/backtests/backtests.service.ts | 322 --------- .../src/exchanges/exchanges.module.ts | 2 - .../src/exchanges/exchanges.service.ts | 42 +- .../src/flows/__tests__/flow-compiler.test.ts | 319 --------- .../src/flows/__tests__/nodes.test.ts | 301 --------- .../worker-service/src/flows/flow-compiler.ts | 367 ---------- .../src/flows/flow-node.interface.ts | 22 - apps/worker-service/src/flows/flows.module.ts | 9 - .../worker-service/src/flows/flows.service.ts | 252 ------- apps/worker-service/src/flows/index.ts | 4 - .../src/flows/nodes/condition-and-or.node.ts | 19 - .../flows/nodes/condition-crossover.node.ts | 54 -- .../flows/nodes/condition-threshold.node.ts | 40 -- .../flows/nodes/data-candle-stream.node.ts | 13 - apps/worker-service/src/flows/nodes/index.ts | 35 - .../flows/nodes/indicator-bollinger.node.ts | 39 -- .../src/flows/nodes/indicator-ema.node.ts | 29 - .../src/flows/nodes/indicator-macd.node.ts | 52 -- .../src/flows/nodes/indicator-rsi.node.ts | 30 - .../src/flows/nodes/order-alert.node.ts | 26 - .../src/flows/nodes/order-market.node.ts | 28 - .../src/orders/orders.module.ts | 5 +- .../src/orders/orders.service.ts | 42 +- .../src/orders/paper-engine.service.ts | 311 --------- .../src/orders/sagas/real-execution-steps.ts | 20 +- .../indicators/bollinger.strategy.test.ts | 78 --- .../indicators/bollinger.strategy.ts | 73 -- .../indicators/combination.strategy.test.ts | 99 --- .../indicators/combination.strategy.ts | 210 ------ .../indicators/macd.strategy.test.ts | 97 --- .../strategies/indicators/macd.strategy.ts | 90 --- .../multi-timeframe.strategy.test.ts | 145 ---- .../indicators/multi-timeframe.strategy.ts | 277 -------- .../indicators/rsi.strategy.test.ts | 79 --- .../src/strategies/indicators/rsi.strategy.ts | 49 -- .../indicators/trend-regime.strategy.test.ts | 106 --- .../indicators/trend-regime.strategy.ts | 266 -------- .../src/strategies/risk/risk.service.test.ts | 639 ------------------ .../src/strategies/risk/risk.service.ts | 630 ----------------- .../sagas/strategy-auto-trade-steps.ts | 309 --------- .../src/strategies/strategies.module.ts | 9 - .../src/strategies/strategies.service.ts | 305 --------- .../src/strategies/strategy.interface.ts | 31 - infra/docker-compose.yml | 35 +- infra/nginx/docker-compose.yml | 5 +- .../20260324032552_init_auth/migration.sql | 58 -- .../migration.sql | 21 - .../20260324171146_add_order/migration.sql | 44 -- .../20260325022619_add_strategy/migration.sql | 53 -- .../migration.sql | 19 - .../migration.sql | 26 - .../migration.sql | 17 - .../migration.sql | 1 - .../migration.sql | 2 - .../migration.sql | 25 - .../migration.sql | 12 - .../20260430165222_init/migration.sql | 214 ++++++ packages/database/prisma/schema.prisma | 121 +--- .../src/bybit/bybit.rest.test.ts | 352 ---------- .../exchange-adapters/src/bybit/bybit.rest.ts | 389 ----------- .../src/bybit/bybit.ws.test.ts | 172 ----- .../exchange-adapters/src/bybit/bybit.ws.ts | 133 ---- packages/exchange-adapters/src/index.ts | 4 - .../src/upbit/upbit.rest.test.ts | 446 ------------ .../exchange-adapters/src/upbit/upbit.rest.ts | 356 ---------- .../src/upbit/upbit.ws.test.ts | 154 ----- .../exchange-adapters/src/upbit/upbit.ws.ts | 107 --- packages/kafka-contracts/src/events.ts | 30 +- packages/kafka-contracts/src/topics.ts | 4 - .../src/factories/balance.factory.ts | 10 +- .../src/factories/candle.factory.ts | 6 +- .../src/factories/exchange-key.factory.ts | 2 +- .../test-utils/src/factories/order.factory.ts | 4 +- .../src/factories/strategy.factory.ts | 47 -- packages/test-utils/src/index.ts | 1 - packages/test-utils/src/msw/handlers.ts | 2 +- packages/types/src/exchange.ts | 2 +- 245 files changed, 410 insertions(+), 20369 deletions(-) delete mode 100644 apps/api-server/e2e/strategies.e2e-test.ts delete mode 100644 apps/api-server/src/flows/commands/create-flow.command.ts delete mode 100644 apps/api-server/src/flows/commands/create-flow.handler.ts delete mode 100644 apps/api-server/src/flows/commands/delete-flow.command.ts delete mode 100644 apps/api-server/src/flows/commands/delete-flow.handler.ts delete mode 100644 apps/api-server/src/flows/commands/index.ts delete mode 100644 apps/api-server/src/flows/commands/request-backtest.command.ts delete mode 100644 apps/api-server/src/flows/commands/request-backtest.handler.ts delete mode 100644 apps/api-server/src/flows/commands/toggle-flow.command.ts delete mode 100644 apps/api-server/src/flows/commands/toggle-flow.handler.ts delete mode 100644 apps/api-server/src/flows/commands/update-flow.command.ts delete mode 100644 apps/api-server/src/flows/commands/update-flow.handler.ts delete mode 100644 apps/api-server/src/flows/dto/backtest-request.dto.ts delete mode 100644 apps/api-server/src/flows/dto/create-flow.dto.ts delete mode 100644 apps/api-server/src/flows/dto/flow-response.dto.ts delete mode 100644 apps/api-server/src/flows/dto/update-flow.dto.ts delete mode 100644 apps/api-server/src/flows/flows-kafka.consumer.ts delete mode 100644 apps/api-server/src/flows/flows-kafka.producer.ts delete mode 100644 apps/api-server/src/flows/flows.controller.ts delete mode 100644 apps/api-server/src/flows/flows.module.ts delete mode 100644 apps/api-server/src/flows/queries/get-backtest-trace.handler.ts delete mode 100644 apps/api-server/src/flows/queries/get-backtest-trace.query.ts delete mode 100644 apps/api-server/src/flows/queries/get-backtests.handler.ts delete mode 100644 apps/api-server/src/flows/queries/get-backtests.query.ts delete mode 100644 apps/api-server/src/flows/queries/get-flow.handler.ts delete mode 100644 apps/api-server/src/flows/queries/get-flow.query.ts delete mode 100644 apps/api-server/src/flows/queries/get-flows.handler.ts delete mode 100644 apps/api-server/src/flows/queries/get-flows.query.ts delete mode 100644 apps/api-server/src/flows/queries/index.ts delete mode 100644 apps/api-server/src/strategies/commands/create-strategy.command.ts delete mode 100644 apps/api-server/src/strategies/commands/create-strategy.handler.test.ts delete mode 100644 apps/api-server/src/strategies/commands/create-strategy.handler.ts delete mode 100644 apps/api-server/src/strategies/commands/delete-strategy.command.ts delete mode 100644 apps/api-server/src/strategies/commands/delete-strategy.handler.test.ts delete mode 100644 apps/api-server/src/strategies/commands/delete-strategy.handler.ts delete mode 100644 apps/api-server/src/strategies/commands/index.ts delete mode 100644 apps/api-server/src/strategies/commands/reorder-strategies.command.ts delete mode 100644 apps/api-server/src/strategies/commands/reorder-strategies.handler.test.ts delete mode 100644 apps/api-server/src/strategies/commands/reorder-strategies.handler.ts delete mode 100644 apps/api-server/src/strategies/commands/toggle-strategy.command.ts delete mode 100644 apps/api-server/src/strategies/commands/toggle-strategy.handler.test.ts delete mode 100644 apps/api-server/src/strategies/commands/toggle-strategy.handler.ts delete mode 100644 apps/api-server/src/strategies/commands/update-strategy.command.ts delete mode 100644 apps/api-server/src/strategies/commands/update-strategy.handler.test.ts delete mode 100644 apps/api-server/src/strategies/commands/update-strategy.handler.ts delete mode 100644 apps/api-server/src/strategies/dto/create-strategy.dto.ts delete mode 100644 apps/api-server/src/strategies/dto/reorder-strategies.dto.ts delete mode 100644 apps/api-server/src/strategies/dto/strategy-response.dto.ts delete mode 100644 apps/api-server/src/strategies/dto/update-strategy.dto.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategies.handler.test.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategies.handler.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategies.query.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategy-logs.handler.test.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategy-logs.handler.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategy-logs.query.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategy-performance.handler.test.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategy-performance.handler.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategy-performance.query.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategy-signals.handler.test.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategy-signals.handler.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategy-signals.query.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategy.handler.test.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategy.handler.ts delete mode 100644 apps/api-server/src/strategies/queries/get-strategy.query.ts delete mode 100644 apps/api-server/src/strategies/queries/index.ts delete mode 100644 apps/api-server/src/strategies/strategies.controller.ts delete mode 100644 apps/api-server/src/strategies/strategies.module.ts delete mode 100644 apps/web/src/app/flows/[id]/page.tsx delete mode 100644 apps/web/src/app/flows/page.tsx delete mode 100644 apps/web/src/app/orders/page.tsx delete mode 100644 apps/web/src/app/strategies/[id]/page.tsx delete mode 100644 apps/web/src/app/strategies/page.tsx delete mode 100644 apps/web/src/components/flows/flow-canvas.tsx delete mode 100644 apps/web/src/components/flows/flow-card.tsx delete mode 100644 apps/web/src/components/flows/flow-toolbar.tsx delete mode 100644 apps/web/src/components/flows/node-help-data.ts delete mode 100644 apps/web/src/components/flows/node-inspector.tsx delete mode 100644 apps/web/src/components/flows/node-palette.tsx delete mode 100644 apps/web/src/components/flows/nodes/base-node.tsx delete mode 100644 apps/web/src/components/flows/template-picker-modal.tsx delete mode 100644 apps/web/src/components/flows/timeline-slider.tsx delete mode 100644 apps/web/src/components/orders/live-price.tsx delete mode 100644 apps/web/src/components/orders/order-form.tsx delete mode 100644 apps/web/src/components/orders/orders-table.tsx delete mode 100644 apps/web/src/components/orders/quick-order-panel.tsx delete mode 100644 apps/web/src/components/orders/symbol-card.tsx delete mode 100644 apps/web/src/components/strategies/create-strategy-form.tsx delete mode 100644 apps/web/src/components/strategies/easy-strategy-wizard.tsx delete mode 100644 apps/web/src/components/strategies/execution-logs.tsx delete mode 100644 apps/web/src/components/strategies/performance-card.tsx delete mode 100644 apps/web/src/components/strategies/strategy-card.test.tsx delete mode 100644 apps/web/src/components/strategies/strategy-card.tsx delete mode 100644 apps/web/src/components/strategies/strategy-info.tsx delete mode 100644 apps/web/src/components/strategy-chart.tsx delete mode 100644 apps/web/src/hooks/use-backtest-ws.ts delete mode 100644 apps/web/src/hooks/use-backtest.ts delete mode 100644 apps/web/src/hooks/use-compare-chart.ts delete mode 100644 apps/web/src/hooks/use-flows.ts delete mode 100644 apps/web/src/hooks/use-order-form.ts delete mode 100644 apps/web/src/hooks/use-order-updates.ts delete mode 100644 apps/web/src/hooks/use-orders.ts delete mode 100644 apps/web/src/hooks/use-strategies.ts delete mode 100644 apps/web/src/hooks/use-strategy-form.ts delete mode 100644 apps/web/src/hooks/use-strategy-performance.ts delete mode 100644 apps/web/src/hooks/use-strategy-runtime.ts delete mode 100644 apps/web/src/hooks/use-strategy-signals.ts delete mode 100644 apps/web/src/lib/indicators.ts delete mode 100644 apps/web/src/mocks/data/flows.ts delete mode 100644 apps/web/src/mocks/data/orders.ts delete mode 100644 apps/web/src/mocks/data/strategies.ts delete mode 100644 apps/web/src/stores/use-flow-store.ts delete mode 100644 apps/web/src/stores/use-order-updates-store.test.ts delete mode 100644 apps/worker-service/src/backtesting/backtest-engine.ts delete mode 100644 apps/worker-service/src/backtesting/backtesting.module.ts delete mode 100644 apps/worker-service/src/backtesting/backtesting.service.ts delete mode 100644 apps/worker-service/src/backtesting/backtesting.types.ts delete mode 100644 apps/worker-service/src/backtesting/data.service.ts delete mode 100644 apps/worker-service/src/backtesting/metrics.calculator.ts delete mode 100644 apps/worker-service/src/backtesting/monte-carlo.service.ts delete mode 100644 apps/worker-service/src/backtesting/optimizer.service.ts delete mode 100644 apps/worker-service/src/backtesting/walk-forward.service.ts delete mode 100644 apps/worker-service/src/backtests/backtests.module.ts delete mode 100644 apps/worker-service/src/backtests/backtests.service.ts delete mode 100644 apps/worker-service/src/flows/__tests__/flow-compiler.test.ts delete mode 100644 apps/worker-service/src/flows/__tests__/nodes.test.ts delete mode 100644 apps/worker-service/src/flows/flow-compiler.ts delete mode 100644 apps/worker-service/src/flows/flow-node.interface.ts delete mode 100644 apps/worker-service/src/flows/flows.module.ts delete mode 100644 apps/worker-service/src/flows/flows.service.ts delete mode 100644 apps/worker-service/src/flows/index.ts delete mode 100644 apps/worker-service/src/flows/nodes/condition-and-or.node.ts delete mode 100644 apps/worker-service/src/flows/nodes/condition-crossover.node.ts delete mode 100644 apps/worker-service/src/flows/nodes/condition-threshold.node.ts delete mode 100644 apps/worker-service/src/flows/nodes/data-candle-stream.node.ts delete mode 100644 apps/worker-service/src/flows/nodes/index.ts delete mode 100644 apps/worker-service/src/flows/nodes/indicator-bollinger.node.ts delete mode 100644 apps/worker-service/src/flows/nodes/indicator-ema.node.ts delete mode 100644 apps/worker-service/src/flows/nodes/indicator-macd.node.ts delete mode 100644 apps/worker-service/src/flows/nodes/indicator-rsi.node.ts delete mode 100644 apps/worker-service/src/flows/nodes/order-alert.node.ts delete mode 100644 apps/worker-service/src/flows/nodes/order-market.node.ts delete mode 100644 apps/worker-service/src/orders/paper-engine.service.ts delete mode 100644 apps/worker-service/src/strategies/indicators/bollinger.strategy.test.ts delete mode 100644 apps/worker-service/src/strategies/indicators/bollinger.strategy.ts delete mode 100644 apps/worker-service/src/strategies/indicators/combination.strategy.test.ts delete mode 100644 apps/worker-service/src/strategies/indicators/combination.strategy.ts delete mode 100644 apps/worker-service/src/strategies/indicators/macd.strategy.test.ts delete mode 100644 apps/worker-service/src/strategies/indicators/macd.strategy.ts delete mode 100644 apps/worker-service/src/strategies/indicators/multi-timeframe.strategy.test.ts delete mode 100644 apps/worker-service/src/strategies/indicators/multi-timeframe.strategy.ts delete mode 100644 apps/worker-service/src/strategies/indicators/rsi.strategy.test.ts delete mode 100644 apps/worker-service/src/strategies/indicators/rsi.strategy.ts delete mode 100644 apps/worker-service/src/strategies/indicators/trend-regime.strategy.test.ts delete mode 100644 apps/worker-service/src/strategies/indicators/trend-regime.strategy.ts delete mode 100644 apps/worker-service/src/strategies/risk/risk.service.test.ts delete mode 100644 apps/worker-service/src/strategies/risk/risk.service.ts delete mode 100644 apps/worker-service/src/strategies/sagas/strategy-auto-trade-steps.ts delete mode 100644 apps/worker-service/src/strategies/strategies.module.ts delete mode 100644 apps/worker-service/src/strategies/strategies.service.ts delete mode 100644 apps/worker-service/src/strategies/strategy.interface.ts delete mode 100644 packages/database/prisma/migrations/20260324032552_init_auth/migration.sql delete mode 100644 packages/database/prisma/migrations/20260324155223_add_exchange_key/migration.sql delete mode 100644 packages/database/prisma/migrations/20260324171146_add_order/migration.sql delete mode 100644 packages/database/prisma/migrations/20260325022619_add_strategy/migration.sql delete mode 100644 packages/database/prisma/migrations/20260325082243_add_notification_setting/migration.sql delete mode 100644 packages/database/prisma/migrations/20260326124605_add_saga_execution/migration.sql delete mode 100644 packages/database/prisma/migrations/20260326180421_add_login_history/migration.sql delete mode 100644 packages/database/prisma/migrations/20260326180516_add_login_history/migration.sql delete mode 100644 packages/database/prisma/migrations/20260328083225_add_candle_interval/migration.sql delete mode 100644 packages/database/prisma/migrations/20260401000000_add_candle_table/migration.sql delete mode 100644 packages/database/prisma/migrations/20260401100000_add_strategy_order/migration.sql create mode 100644 packages/database/prisma/migrations/20260430165222_init/migration.sql delete mode 100644 packages/exchange-adapters/src/bybit/bybit.rest.test.ts delete mode 100644 packages/exchange-adapters/src/bybit/bybit.rest.ts delete mode 100644 packages/exchange-adapters/src/bybit/bybit.ws.test.ts delete mode 100644 packages/exchange-adapters/src/bybit/bybit.ws.ts delete mode 100644 packages/exchange-adapters/src/upbit/upbit.rest.test.ts delete mode 100644 packages/exchange-adapters/src/upbit/upbit.rest.ts delete mode 100644 packages/exchange-adapters/src/upbit/upbit.ws.test.ts delete mode 100644 packages/exchange-adapters/src/upbit/upbit.ws.ts delete mode 100644 packages/test-utils/src/factories/strategy.factory.ts diff --git a/apps/api-server/docker-compose.dev.yml b/apps/api-server/docker-compose.dev.yml index d71ec13..3e100b2 100644 --- a/apps/api-server/docker-compose.dev.yml +++ b/apps/api-server/docker-compose.dev.yml @@ -8,7 +8,7 @@ services: volumes: - ../../:/app - /app/node_modules - - ${LOG_DIR:-../../logs}/api-server:/app/logs + - api_server_logs:/app/logs expose: - '3000' depends_on: @@ -35,3 +35,6 @@ services: networks: coin-net: name: coin-net + +volumes: + api_server_logs: diff --git a/apps/api-server/e2e/orders.e2e-test.ts b/apps/api-server/e2e/orders.e2e-test.ts index 7f8ee50..4ed2b23 100644 --- a/apps/api-server/e2e/orders.e2e-test.ts +++ b/apps/api-server/e2e/orders.e2e-test.ts @@ -9,7 +9,7 @@ describe('Orders E2E', () => { method: 'POST', cookies, body: JSON.stringify({ - exchange: 'upbit', + exchange: 'binance', symbol: 'KRW-BTC', side: 'buy', type: 'market', diff --git a/apps/api-server/e2e/strategies.e2e-test.ts b/apps/api-server/e2e/strategies.e2e-test.ts deleted file mode 100644 index e41b8e7..0000000 --- a/apps/api-server/e2e/strategies.e2e-test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { api, signupAndLogin } from './helpers'; - -describe('Strategies E2E', () => { - it('POST /strategies → 페이퍼 전략을 생성해야 한다', async () => { - const cookies = await signupAndLogin(); - - const res = await api('/strategies', { - method: 'POST', - cookies, - body: JSON.stringify({ - name: 'E2E RSI 전략', - type: 'rsi', - exchange: 'upbit', - symbol: 'KRW-BTC', - mode: 'signal', - tradingMode: 'paper', - config: { period: 14, overbought: 70, oversold: 30 }, - }), - }); - - expect(res.status).toBe(201); - const body = await res.json(); - expect(body.id).toBeDefined(); - expect(body.name).toBe('E2E RSI 전략'); - }); - - it('GET /strategies → 전략 목록을 반환해야 한다', async () => { - const cookies = await signupAndLogin(); - - const res = await api('/strategies', { cookies }); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(Array.isArray(body)).toBe(true); - }); - - it('전략 CRUD 전체 플로우', async () => { - const cookies = await signupAndLogin(); - - // Create - const createRes = await api('/strategies', { - method: 'POST', - cookies, - body: JSON.stringify({ - name: 'CRUD 테스트', - type: 'macd', - exchange: 'upbit', - symbol: 'KRW-BTC', - mode: 'signal', - tradingMode: 'paper', - config: { fastPeriod: 12, slowPeriod: 26, signalPeriod: 9 }, - }), - }); - expect(createRes.status).toBe(201); - const { id } = await createRes.json(); - - // Read - const getRes = await api(`/strategies/${id}`, { cookies }); - expect(getRes.status).toBe(200); - const strategy = await getRes.json(); - expect(strategy.name).toBe('CRUD 테스트'); - - // Update - const updateRes = await api(`/strategies/${id}`, { - method: 'PATCH', - cookies, - body: JSON.stringify({ name: '수정된 전략' }), - }); - expect(updateRes.status).toBe(200); - - // Toggle - const toggleRes = await api(`/strategies/${id}/toggle`, { - method: 'PATCH', - cookies, - }); - expect(toggleRes.status).toBe(200); - const toggled = await toggleRes.json(); - expect(toggled.enabled).toBe(true); - - // Delete - const deleteRes = await api(`/strategies/${id}`, { - method: 'DELETE', - cookies, - }); - expect(deleteRes.status).toBe(200); - - // Verify deleted - const verifyRes = await api(`/strategies/${id}`, { cookies }); - expect(verifyRes.status).toBe(404); - }); - - it('GET /strategies → 미인증 시 401을 반환해야 한다', async () => { - const res = await api('/strategies'); - expect(res.status).toBe(401); - }); -}); diff --git a/apps/api-server/src/activity/activity.service.ts b/apps/api-server/src/activity/activity.service.ts index b7d7e8f..023fa4b 100644 --- a/apps/api-server/src/activity/activity.service.ts +++ b/apps/api-server/src/activity/activity.service.ts @@ -3,7 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; export interface ActivityItem { id: string; - type: 'order' | 'strategy_signal' | 'strategy_order' | 'risk_blocked' | 'login'; + type: 'order' | 'login'; title: string; description: string; exchange?: string; @@ -25,8 +25,7 @@ export class ActivityService { ): Promise<{ items: ActivityItem[]; nextCursor: string | null }> { const cursorDate = cursor ? new Date(cursor) : undefined; - // Fetch from all 3 sources in parallel - const [orders, strategyLogs, logins] = await Promise.all([ + const [orders, logins] = await Promise.all([ this.prisma.order.findMany({ where: { userId, @@ -35,15 +34,6 @@ export class ActivityService { orderBy: { createdAt: 'desc' }, take: limit + 1, }), - this.prisma.strategyLog.findMany({ - where: { - strategy: { userId }, - ...(cursorDate ? { createdAt: { lt: cursorDate } } : {}), - }, - include: { strategy: { select: { name: true, exchange: true, symbol: true, id: true } } }, - orderBy: { createdAt: 'desc' }, - take: limit + 1, - }), this.prisma.loginHistory.findMany({ where: { userId, @@ -54,7 +44,6 @@ export class ActivityService { }), ]); - // Map to unified type const orderItems: ActivityItem[] = orders.map((o) => ({ id: `order-${o.id}`, type: 'order' as const, @@ -68,28 +57,6 @@ export class ActivityService { createdAt: o.createdAt, })); - const strategyItems: ActivityItem[] = strategyLogs.map((log) => { - const details = log.details as Record; - const action = log.action; - let type: ActivityItem['type'] = 'strategy_signal'; - if (action === 'order_placed') type = 'strategy_order'; - if (action === 'risk_blocked') type = 'risk_blocked'; - - return { - id: `strategy-${log.id}`, - type, - title: `${log.strategy.name} — ${action.replace('_', ' ')}`, - description: log.signal - ? `${log.signal.toUpperCase()} @ ${details.price || ''} (${details.reason || ''})` - : String(details.reason || details.error || ''), - exchange: log.strategy.exchange, - symbol: log.strategy.symbol, - side: log.signal || undefined, - link: `/strategies/${log.strategy.id}`, - createdAt: log.createdAt, - }; - }); - const loginItems: ActivityItem[] = logins.map((l) => ({ id: `login-${l.id}`, type: 'login' as const, @@ -100,8 +67,7 @@ export class ActivityService { createdAt: l.createdAt, })); - // Merge sort by createdAt desc - const all = [...orderItems, ...strategyItems, ...loginItems].sort( + const all = [...orderItems, ...loginItems].sort( (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), ); diff --git a/apps/api-server/src/app.module.ts b/apps/api-server/src/app.module.ts index 4edc344..b324344 100644 --- a/apps/api-server/src/app.module.ts +++ b/apps/api-server/src/app.module.ts @@ -10,11 +10,9 @@ import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; import { ExchangeKeysModule } from './exchange-keys/exchange-keys.module'; import { OrdersModule } from './orders/orders.module'; -import { StrategiesModule } from './strategies/strategies.module'; import { NotificationsModule } from './notifications/notifications.module'; import { PortfolioModule } from './portfolio/portfolio.module'; import { ActivityModule } from './activity/activity.module'; -import { FlowsModule } from './flows/flows.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; @Module({ @@ -47,11 +45,9 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; MarketsModule, ExchangeKeysModule, OrdersModule, - StrategiesModule, NotificationsModule, PortfolioModule, ActivityModule, - FlowsModule, ], controllers: [AppController], providers: [ diff --git a/apps/api-server/src/exchange-keys/commands/create-exchange-key.handler.test.ts b/apps/api-server/src/exchange-keys/commands/create-exchange-key.handler.test.ts index 2f63945..a137717 100644 --- a/apps/api-server/src/exchange-keys/commands/create-exchange-key.handler.test.ts +++ b/apps/api-server/src/exchange-keys/commands/create-exchange-key.handler.test.ts @@ -39,13 +39,13 @@ describe('CreateExchangeKeyHandler', () => { it('암호화된 자격증명으로 거래소 키를 생성/upsert해야 한다', async () => { mockPrisma.exchangeKey.upsert.mockResolvedValue({ id: 'key-1', - exchange: 'upbit', + exchange: 'binance', createdAt: new Date(), }); const result = await handler.execute( new CreateExchangeKeyCommand('user-1', { - exchange: 'upbit', + exchange: 'binance', apiKey: 'my-api-key', secretKey: 'my-secret', } as never), @@ -65,7 +65,7 @@ describe('CreateExchangeKeyHandler', () => { await expect( handler.execute( new CreateExchangeKeyCommand('user-1', { - exchange: 'upbit', + exchange: 'binance', apiKey: 'key', secretKey: 'secret', } as never), @@ -79,7 +79,7 @@ describe('CreateExchangeKeyHandler', () => { await expect( handler.execute( new CreateExchangeKeyCommand('user-1', { - exchange: 'upbit', + exchange: 'binance', apiKey: 'bad-key', secretKey: 'bad-secret', } as never), diff --git a/apps/api-server/src/exchange-keys/commands/create-exchange-key.handler.ts b/apps/api-server/src/exchange-keys/commands/create-exchange-key.handler.ts index c5f5581..58a5705 100644 --- a/apps/api-server/src/exchange-keys/commands/create-exchange-key.handler.ts +++ b/apps/api-server/src/exchange-keys/commands/create-exchange-key.handler.ts @@ -3,14 +3,12 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../prisma/prisma.service'; import { encrypt } from '@coin/utils'; -import { UpbitRest, BinanceRest, BybitRest, IExchangeRest } from '@coin/exchange-adapters'; +import { BinanceRest, IExchangeRest } from '@coin/exchange-adapters'; import type { ExchangeId, ExchangeCredentials } from '@coin/types'; import { CreateExchangeKeyCommand } from './create-exchange-key.command'; const REST_ADAPTERS: Record IExchangeRest> = { - upbit: () => new UpbitRest(), binance: () => new BinanceRest(), - bybit: () => new BybitRest(), }; @CommandHandler(CreateExchangeKeyCommand) diff --git a/apps/api-server/src/exchange-keys/dto/create-exchange-key.dto.ts b/apps/api-server/src/exchange-keys/dto/create-exchange-key.dto.ts index 3dbaa2d..c030eef 100644 --- a/apps/api-server/src/exchange-keys/dto/create-exchange-key.dto.ts +++ b/apps/api-server/src/exchange-keys/dto/create-exchange-key.dto.ts @@ -5,9 +5,9 @@ export class CreateExchangeKeyDto { @ApiProperty({ description: '대상 거래소', example: 'binance', - enum: ['upbit', 'binance', 'bybit'], + enum: ['binance'], }) - @IsIn(['upbit', 'binance', 'bybit']) + @IsIn(['binance']) exchange!: string; @ApiProperty({ description: '거래소 API 키', example: 'aB3dEfGhIjKlMnOpQrStUvWxYz012345' }) diff --git a/apps/api-server/src/exchange-keys/queries/get-balances.handler.ts b/apps/api-server/src/exchange-keys/queries/get-balances.handler.ts index 8486bbe..d27ec07 100644 --- a/apps/api-server/src/exchange-keys/queries/get-balances.handler.ts +++ b/apps/api-server/src/exchange-keys/queries/get-balances.handler.ts @@ -3,14 +3,12 @@ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../prisma/prisma.service'; import { decrypt } from '@coin/utils'; -import { UpbitRest, BinanceRest, BybitRest, IExchangeRest } from '@coin/exchange-adapters'; +import { BinanceRest, IExchangeRest } from '@coin/exchange-adapters'; import type { ExchangeId, ExchangeCredentials } from '@coin/types'; import { GetBalancesQuery } from './get-balances.query'; const REST_ADAPTERS: Record IExchangeRest> = { - upbit: () => new UpbitRest(), binance: () => new BinanceRest(), - bybit: () => new BybitRest(), }; @QueryHandler(GetBalancesQuery) diff --git a/apps/api-server/src/exchange-keys/queries/get-exchange-keys.handler.test.ts b/apps/api-server/src/exchange-keys/queries/get-exchange-keys.handler.test.ts index 4373d33..cd7d10c 100644 --- a/apps/api-server/src/exchange-keys/queries/get-exchange-keys.handler.test.ts +++ b/apps/api-server/src/exchange-keys/queries/get-exchange-keys.handler.test.ts @@ -13,7 +13,7 @@ describe('GetExchangeKeysHandler', () => { }); it('민감한 데이터 없이 거래소 키를 반환해야 한다', async () => { - const keys = [{ id: 'key-1', exchange: 'upbit', createdAt: new Date() }]; + const keys = [{ id: 'key-1', exchange: 'binance', createdAt: new Date() }]; mockPrisma.exchangeKey.findMany.mockResolvedValue(keys); const result = await handler.execute(new GetExchangeKeysQuery('user-1')); diff --git a/apps/api-server/src/exchange-keys/queries/get-markets.handler.ts b/apps/api-server/src/exchange-keys/queries/get-markets.handler.ts index 5094eea..fdd6595 100644 --- a/apps/api-server/src/exchange-keys/queries/get-markets.handler.ts +++ b/apps/api-server/src/exchange-keys/queries/get-markets.handler.ts @@ -1,14 +1,12 @@ import { NotFoundException } from '@nestjs/common'; import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { PrismaService } from '../../prisma/prisma.service'; -import { UpbitRest, BinanceRest, BybitRest, IExchangeRest } from '@coin/exchange-adapters'; +import { BinanceRest, IExchangeRest } from '@coin/exchange-adapters'; import type { ExchangeId } from '@coin/types'; import { GetMarketsQuery } from './get-markets.query'; const REST_ADAPTERS: Record IExchangeRest> = { - upbit: () => new UpbitRest(), binance: () => new BinanceRest(), - bybit: () => new BybitRest(), }; @QueryHandler(GetMarketsQuery) diff --git a/apps/api-server/src/exchange-keys/queries/get-open-orders.handler.ts b/apps/api-server/src/exchange-keys/queries/get-open-orders.handler.ts index fafd767..2444149 100644 --- a/apps/api-server/src/exchange-keys/queries/get-open-orders.handler.ts +++ b/apps/api-server/src/exchange-keys/queries/get-open-orders.handler.ts @@ -3,14 +3,12 @@ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../prisma/prisma.service'; import { decrypt } from '@coin/utils'; -import { UpbitRest, BinanceRest, BybitRest, IExchangeRest } from '@coin/exchange-adapters'; +import { BinanceRest, IExchangeRest } from '@coin/exchange-adapters'; import type { ExchangeId, ExchangeCredentials } from '@coin/types'; import { GetOpenOrdersQuery } from './get-open-orders.query'; const REST_ADAPTERS: Record IExchangeRest> = { - upbit: () => new UpbitRest(), binance: () => new BinanceRest(), - bybit: () => new BybitRest(), }; @QueryHandler(GetOpenOrdersQuery) diff --git a/apps/api-server/src/flows/commands/create-flow.command.ts b/apps/api-server/src/flows/commands/create-flow.command.ts deleted file mode 100644 index cc9077e..0000000 --- a/apps/api-server/src/flows/commands/create-flow.command.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { CreateFlowDto } from '../dto/create-flow.dto'; - -export class CreateFlowCommand { - constructor( - public readonly userId: string, - public readonly dto: CreateFlowDto, - ) {} -} diff --git a/apps/api-server/src/flows/commands/create-flow.handler.ts b/apps/api-server/src/flows/commands/create-flow.handler.ts deleted file mode 100644 index a112db0..0000000 --- a/apps/api-server/src/flows/commands/create-flow.handler.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; -import { CreateFlowCommand } from './create-flow.command'; -import { FLOW_LIMITS, NODE_TYPE_REGISTRY } from '@coin/types'; - -@CommandHandler(CreateFlowCommand) -export class CreateFlowHandler implements ICommandHandler { - private readonly logger = new Logger(CreateFlowHandler.name); - - constructor(private readonly prisma: PrismaService) {} - - async execute(command: CreateFlowCommand) { - const { userId, dto } = command; - const { definition } = dto; - - this.validateDefinition(definition); - - if (dto.tradingMode === 'real' && !dto.exchangeKeyId) { - throw new BadRequestException('exchangeKeyId is required for real trading mode'); - } - - if (dto.tradingMode === 'real' && dto.exchangeKeyId) { - const key = await this.prisma.exchangeKey.findFirst({ - where: { id: dto.exchangeKeyId, userId }, - }); - if (!key) throw new NotFoundException('Exchange key not found'); - } - - const flow = await this.prisma.flow.create({ - data: { - userId, - name: dto.name, - description: dto.description || null, - definition: definition as never, - exchange: dto.exchange, - symbol: dto.symbol, - candleInterval: dto.candleInterval || '1h', - tradingMode: dto.tradingMode || 'paper', - exchangeKeyId: dto.tradingMode === 'real' ? dto.exchangeKeyId : null, - riskConfig: (dto.riskConfig || {}) as never, - }, - }); - - this.logger.log(`Flow created: ${flow.id} (${dto.name})`); - return flow; - } - - private validateDefinition(definition: CreateFlowCommand['dto']['definition']) { - if (!definition?.nodes || !definition?.edges) { - throw new BadRequestException('definition must contain nodes and edges arrays'); - } - - if (definition.nodes.length > FLOW_LIMITS.MAX_NODES) { - throw new BadRequestException(`Maximum ${FLOW_LIMITS.MAX_NODES} nodes allowed`); - } - - if (definition.edges.length > FLOW_LIMITS.MAX_EDGES) { - throw new BadRequestException(`Maximum ${FLOW_LIMITS.MAX_EDGES} edges allowed`); - } - - const nodeIds = new Set(definition.nodes.map((n) => n.id)); - if (nodeIds.size !== definition.nodes.length) { - throw new BadRequestException('Duplicate node IDs found'); - } - - for (const node of definition.nodes) { - if (!NODE_TYPE_REGISTRY[node.subtype]) { - throw new BadRequestException(`Unknown node subtype: ${node.subtype}`); - } - } - - for (const edge of definition.edges) { - if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) { - throw new BadRequestException(`Edge references unknown node: ${edge.id}`); - } - } - } -} diff --git a/apps/api-server/src/flows/commands/delete-flow.command.ts b/apps/api-server/src/flows/commands/delete-flow.command.ts deleted file mode 100644 index b5771cb..0000000 --- a/apps/api-server/src/flows/commands/delete-flow.command.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class DeleteFlowCommand { - constructor( - public readonly userId: string, - public readonly id: string, - ) {} -} diff --git a/apps/api-server/src/flows/commands/delete-flow.handler.ts b/apps/api-server/src/flows/commands/delete-flow.handler.ts deleted file mode 100644 index 3cb8b9d..0000000 --- a/apps/api-server/src/flows/commands/delete-flow.handler.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { Logger, NotFoundException } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; -import { DeleteFlowCommand } from './delete-flow.command'; - -@CommandHandler(DeleteFlowCommand) -export class DeleteFlowHandler implements ICommandHandler { - private readonly logger = new Logger(DeleteFlowHandler.name); - - constructor(private readonly prisma: PrismaService) {} - - async execute(command: DeleteFlowCommand) { - const { userId, id } = command; - - const flow = await this.prisma.flow.findFirst({ - where: { id, userId }, - }); - if (!flow) throw new NotFoundException('Flow not found'); - - await this.prisma.flow.delete({ where: { id } }); - - this.logger.log(`Flow deleted: ${id}`); - return { success: true }; - } -} diff --git a/apps/api-server/src/flows/commands/index.ts b/apps/api-server/src/flows/commands/index.ts deleted file mode 100644 index 02d49b9..0000000 --- a/apps/api-server/src/flows/commands/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { CreateFlowCommand } from './create-flow.command'; -export { UpdateFlowCommand } from './update-flow.command'; -export { DeleteFlowCommand } from './delete-flow.command'; -export { ToggleFlowCommand } from './toggle-flow.command'; -export { RequestBacktestCommand } from './request-backtest.command'; - -import { CreateFlowHandler } from './create-flow.handler'; -import { UpdateFlowHandler } from './update-flow.handler'; -import { DeleteFlowHandler } from './delete-flow.handler'; -import { ToggleFlowHandler } from './toggle-flow.handler'; -import { RequestBacktestHandler } from './request-backtest.handler'; - -export const FlowCommandHandlers = [ - CreateFlowHandler, - UpdateFlowHandler, - DeleteFlowHandler, - ToggleFlowHandler, - RequestBacktestHandler, -]; diff --git a/apps/api-server/src/flows/commands/request-backtest.command.ts b/apps/api-server/src/flows/commands/request-backtest.command.ts deleted file mode 100644 index 9c5d58d..0000000 --- a/apps/api-server/src/flows/commands/request-backtest.command.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { BacktestRequestDto } from '../dto/backtest-request.dto'; - -export class RequestBacktestCommand { - constructor( - public readonly userId: string, - public readonly flowId: string, - public readonly dto: BacktestRequestDto, - ) {} -} diff --git a/apps/api-server/src/flows/commands/request-backtest.handler.ts b/apps/api-server/src/flows/commands/request-backtest.handler.ts deleted file mode 100644 index faa02ed..0000000 --- a/apps/api-server/src/flows/commands/request-backtest.handler.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; -import { RequestBacktestCommand } from './request-backtest.command'; -import { FLOW_LIMITS } from '@coin/types'; -import { FlowsKafkaProducer } from '../flows-kafka.producer'; - -@CommandHandler(RequestBacktestCommand) -export class RequestBacktestHandler implements ICommandHandler { - private readonly logger = new Logger(RequestBacktestHandler.name); - - constructor( - private readonly prisma: PrismaService, - private readonly kafkaProducer: FlowsKafkaProducer, - ) {} - - async execute(command: RequestBacktestCommand) { - const { userId, flowId, dto } = command; - - const flow = await this.prisma.flow.findFirst({ - where: { id: flowId, userId }, - }); - if (!flow) throw new NotFoundException('Flow not found'); - - const startDate = new Date(dto.startDate); - const endDate = new Date(dto.endDate); - const diffDays = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24); - - if (diffDays <= 0) { - throw new BadRequestException('endDate must be after startDate'); - } - if (diffDays > FLOW_LIMITS.MAX_BACKTEST_DAYS) { - throw new BadRequestException( - `Maximum backtest range is ${FLOW_LIMITS.MAX_BACKTEST_DAYS} days`, - ); - } - - // Enforce max backtests per flow — prune oldest if at limit - const existingCount = await this.prisma.backtest.count({ - where: { flowId }, - }); - if (existingCount >= FLOW_LIMITS.MAX_BACKTESTS_PER_FLOW) { - const oldest = await this.prisma.backtest.findMany({ - where: { flowId }, - orderBy: { createdAt: 'asc' }, - take: existingCount - FLOW_LIMITS.MAX_BACKTESTS_PER_FLOW + 1, - select: { id: true }, - }); - await this.prisma.backtest.deleteMany({ - where: { id: { in: oldest.map((b) => b.id) } }, - }); - } - - const backtest = await this.prisma.backtest.create({ - data: { - flowId, - startDate, - endDate, - status: 'pending', - }, - }); - - await this.kafkaProducer.publishBacktestRequested({ - backtestId: backtest.id, - flowId, - userId, - startDate: dto.startDate, - endDate: dto.endDate, - }); - - this.logger.log(`Backtest requested: ${backtest.id} for flow ${flowId}`); - return { backtestId: backtest.id }; - } -} diff --git a/apps/api-server/src/flows/commands/toggle-flow.command.ts b/apps/api-server/src/flows/commands/toggle-flow.command.ts deleted file mode 100644 index 734e918..0000000 --- a/apps/api-server/src/flows/commands/toggle-flow.command.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class ToggleFlowCommand { - constructor( - public readonly userId: string, - public readonly id: string, - ) {} -} diff --git a/apps/api-server/src/flows/commands/toggle-flow.handler.ts b/apps/api-server/src/flows/commands/toggle-flow.handler.ts deleted file mode 100644 index e6f3111..0000000 --- a/apps/api-server/src/flows/commands/toggle-flow.handler.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { Logger, NotFoundException } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; -import { ToggleFlowCommand } from './toggle-flow.command'; - -@CommandHandler(ToggleFlowCommand) -export class ToggleFlowHandler implements ICommandHandler { - private readonly logger = new Logger(ToggleFlowHandler.name); - - constructor(private readonly prisma: PrismaService) {} - - async execute(command: ToggleFlowCommand) { - const { userId, id } = command; - - const flow = await this.prisma.flow.findFirst({ - where: { id, userId }, - }); - if (!flow) throw new NotFoundException('Flow not found'); - - const updated = await this.prisma.flow.update({ - where: { id }, - data: { enabled: !flow.enabled }, - }); - - this.logger.log(`Flow toggled: ${id} → ${updated.enabled ? 'enabled' : 'disabled'}`); - return updated; - } -} diff --git a/apps/api-server/src/flows/commands/update-flow.command.ts b/apps/api-server/src/flows/commands/update-flow.command.ts deleted file mode 100644 index eae2fb8..0000000 --- a/apps/api-server/src/flows/commands/update-flow.command.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { UpdateFlowDto } from '../dto/update-flow.dto'; - -export class UpdateFlowCommand { - constructor( - public readonly userId: string, - public readonly id: string, - public readonly dto: UpdateFlowDto, - ) {} -} diff --git a/apps/api-server/src/flows/commands/update-flow.handler.ts b/apps/api-server/src/flows/commands/update-flow.handler.ts deleted file mode 100644 index 3a2728e..0000000 --- a/apps/api-server/src/flows/commands/update-flow.handler.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; -import { UpdateFlowCommand } from './update-flow.command'; -import { FLOW_LIMITS, NODE_TYPE_REGISTRY } from '@coin/types'; - -@CommandHandler(UpdateFlowCommand) -export class UpdateFlowHandler implements ICommandHandler { - private readonly logger = new Logger(UpdateFlowHandler.name); - - constructor(private readonly prisma: PrismaService) {} - - async execute(command: UpdateFlowCommand) { - const { userId, id, dto } = command; - - const flow = await this.prisma.flow.findFirst({ - where: { id, userId }, - }); - if (!flow) throw new NotFoundException('Flow not found'); - - if (dto.definition) { - this.validateDefinition(dto.definition); - } - - if (dto.tradingMode === 'real' && !dto.exchangeKeyId && !flow.exchangeKeyId) { - throw new BadRequestException('exchangeKeyId is required for real trading mode'); - } - - if (dto.exchangeKeyId) { - const key = await this.prisma.exchangeKey.findFirst({ - where: { id: dto.exchangeKeyId, userId }, - }); - if (!key) throw new NotFoundException('Exchange key not found'); - } - - const updated = await this.prisma.flow.update({ - where: { id }, - data: { - ...(dto.name !== undefined && { name: dto.name }), - ...(dto.description !== undefined && { description: dto.description }), - ...(dto.definition !== undefined && { definition: dto.definition as never }), - ...(dto.candleInterval !== undefined && { candleInterval: dto.candleInterval }), - ...(dto.tradingMode !== undefined && { tradingMode: dto.tradingMode }), - ...(dto.exchangeKeyId !== undefined && { - exchangeKeyId: dto.tradingMode === 'real' ? dto.exchangeKeyId : null, - }), - ...(dto.riskConfig !== undefined && { riskConfig: dto.riskConfig as never }), - }, - }); - - this.logger.log(`Flow updated: ${id}`); - return updated; - } - - private validateDefinition(definition: NonNullable) { - if (!definition?.nodes || !definition?.edges) { - throw new BadRequestException('definition must contain nodes and edges arrays'); - } - - if (definition.nodes.length > FLOW_LIMITS.MAX_NODES) { - throw new BadRequestException(`Maximum ${FLOW_LIMITS.MAX_NODES} nodes allowed`); - } - - if (definition.edges.length > FLOW_LIMITS.MAX_EDGES) { - throw new BadRequestException(`Maximum ${FLOW_LIMITS.MAX_EDGES} edges allowed`); - } - - const nodeIds = new Set(definition.nodes.map((n) => n.id)); - if (nodeIds.size !== definition.nodes.length) { - throw new BadRequestException('Duplicate node IDs found'); - } - - for (const node of definition.nodes) { - if (!NODE_TYPE_REGISTRY[node.subtype]) { - throw new BadRequestException(`Unknown node subtype: ${node.subtype}`); - } - } - - for (const edge of definition.edges) { - if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) { - throw new BadRequestException(`Edge references unknown node: ${edge.id}`); - } - } - } -} diff --git a/apps/api-server/src/flows/dto/backtest-request.dto.ts b/apps/api-server/src/flows/dto/backtest-request.dto.ts deleted file mode 100644 index 9e31349..0000000 --- a/apps/api-server/src/flows/dto/backtest-request.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsDateString } from 'class-validator'; - -export class BacktestRequestDto { - @ApiProperty({ description: '백테스트 시작 날짜 (ISO 8601)' }) - @IsDateString() - startDate!: string; - - @ApiProperty({ description: '백테스트 종료 날짜 (ISO 8601)' }) - @IsDateString() - endDate!: string; -} diff --git a/apps/api-server/src/flows/dto/create-flow.dto.ts b/apps/api-server/src/flows/dto/create-flow.dto.ts deleted file mode 100644 index e2dc3ae..0000000 --- a/apps/api-server/src/flows/dto/create-flow.dto.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsIn, IsOptional, IsObject, IsArray, ValidateNested } from 'class-validator'; - -export class CreateFlowDto { - @ApiProperty({ description: '플로우 이름' }) - @IsString() - name!: string; - - @ApiPropertyOptional({ description: '플로우 설명' }) - @IsOptional() - @IsString() - description?: string; - - @ApiProperty({ description: '플로우 정의 (노드 + 엣지)', type: Object }) - @IsObject() - definition!: { - nodes: Array<{ - id: string; - type: 'data' | 'indicator' | 'condition' | 'order' | 'flow-control'; - subtype: string; - position: { x: number; y: number }; - config: Record; - }>; - edges: Array<{ - id: string; - source: string; - target: string; - sourceHandle?: string; - targetHandle?: string; - }>; - }; - - @ApiProperty({ enum: ['upbit', 'binance', 'bybit'], description: '거래소' }) - @IsIn(['upbit', 'binance', 'bybit']) - exchange!: string; - - @ApiProperty({ description: '거래 심볼 (e.g., BTC/USDT)' }) - @IsString() - symbol!: string; - - @ApiPropertyOptional({ - enum: ['1m', '5m', '15m', '1h', '4h', '1d'], - description: '캔들 간격', - }) - @IsOptional() - @IsIn(['1m', '5m', '15m', '1h', '4h', '1d']) - candleInterval?: string; - - @ApiPropertyOptional({ enum: ['paper', 'real'], description: '트레이딩 모드' }) - @IsOptional() - @IsIn(['paper', 'real']) - tradingMode?: string; - - @ApiPropertyOptional({ description: '거래소 API 키 ID' }) - @IsOptional() - @IsString() - exchangeKeyId?: string; - - @ApiPropertyOptional({ description: '리스크 설정', type: Object }) - @IsOptional() - @IsObject() - riskConfig?: Record; -} diff --git a/apps/api-server/src/flows/dto/flow-response.dto.ts b/apps/api-server/src/flows/dto/flow-response.dto.ts deleted file mode 100644 index 0ddd2c8..0000000 --- a/apps/api-server/src/flows/dto/flow-response.dto.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class FlowResponse { - @ApiProperty() - id!: string; - - @ApiProperty() - name!: string; - - @ApiPropertyOptional() - description!: string | null; - - @ApiProperty() - definition!: object; - - @ApiProperty() - exchange!: string; - - @ApiProperty() - symbol!: string; - - @ApiProperty() - candleInterval!: string; - - @ApiProperty() - enabled!: boolean; - - @ApiProperty() - tradingMode!: string; - - @ApiPropertyOptional() - riskConfig!: object | null; - - @ApiProperty() - createdAt!: string; - - @ApiProperty() - updatedAt!: string; -} - -export class BacktestResponse { - @ApiProperty() - id!: string; - - @ApiProperty() - flowId!: string; - - @ApiProperty() - startDate!: string; - - @ApiProperty() - endDate!: string; - - @ApiProperty() - status!: string; - - @ApiPropertyOptional() - summary!: object | null; - - @ApiProperty() - createdAt!: string; -} - -export class BacktestTraceResponse { - @ApiProperty() - id!: string; - - @ApiProperty() - timestamp!: string; - - @ApiProperty() - nodeId!: string; - - @ApiProperty() - input!: object; - - @ApiProperty() - output!: object; - - @ApiProperty() - fired!: boolean; - - @ApiProperty() - durationMs!: number; -} - -export class BacktestTraceListResponse { - @ApiProperty({ type: [BacktestTraceResponse] }) - items!: BacktestTraceResponse[]; - - @ApiProperty() - total!: number; -} diff --git a/apps/api-server/src/flows/dto/update-flow.dto.ts b/apps/api-server/src/flows/dto/update-flow.dto.ts deleted file mode 100644 index 0573e6b..0000000 --- a/apps/api-server/src/flows/dto/update-flow.dto.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsIn, IsOptional, IsObject } from 'class-validator'; - -export class UpdateFlowDto { - @ApiPropertyOptional({ description: '플로우 이름' }) - @IsOptional() - @IsString() - name?: string; - - @ApiPropertyOptional({ description: '플로우 설명' }) - @IsOptional() - @IsString() - description?: string; - - @ApiPropertyOptional({ description: '플로우 정의 (노드 + 엣지)', type: Object }) - @IsOptional() - @IsObject() - definition?: { - nodes: Array<{ - id: string; - type: 'data' | 'indicator' | 'condition' | 'order' | 'flow-control'; - subtype: string; - position: { x: number; y: number }; - config: Record; - }>; - edges: Array<{ - id: string; - source: string; - target: string; - sourceHandle?: string; - targetHandle?: string; - }>; - }; - - @ApiPropertyOptional({ - enum: ['1m', '5m', '15m', '1h', '4h', '1d'], - description: '캔들 간격', - }) - @IsOptional() - @IsIn(['1m', '5m', '15m', '1h', '4h', '1d']) - candleInterval?: string; - - @ApiPropertyOptional({ enum: ['paper', 'real'], description: '트레이딩 모드' }) - @IsOptional() - @IsIn(['paper', 'real']) - tradingMode?: string; - - @ApiPropertyOptional({ description: '거래소 API 키 ID' }) - @IsOptional() - @IsString() - exchangeKeyId?: string; - - @ApiPropertyOptional({ description: '리스크 설정', type: Object }) - @IsOptional() - @IsObject() - riskConfig?: Record; -} diff --git a/apps/api-server/src/flows/flows-kafka.consumer.ts b/apps/api-server/src/flows/flows-kafka.consumer.ts deleted file mode 100644 index 59525d2..0000000 --- a/apps/api-server/src/flows/flows-kafka.consumer.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { Kafka, Consumer } from 'kafkajs'; -import { KAFKA_TOPICS } from '@coin/kafka-contracts'; -import type { BacktestCompletedEvent } from '@coin/kafka-contracts'; -import { PrismaService } from '../prisma/prisma.service'; - -export interface BacktestCompletedPayload { - backtestId: string; - flowId: string; - userId: string; - status: 'completed' | 'failed'; - error?: string; -} - -@Injectable() -export class FlowsKafkaConsumer implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(FlowsKafkaConsumer.name); - private kafka: Kafka; - private consumer: Consumer; - private listeners: ((payload: BacktestCompletedPayload) => void)[] = []; - - constructor(private readonly prisma: PrismaService) { - this.kafka = new Kafka({ - clientId: 'api-server-flows', - brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), - }); - this.consumer = this.kafka.consumer({ groupId: 'api-server-backtest' }); - } - - async onModuleInit() { - await this.consumer.connect(); - await this.consumer.subscribe({ - topic: KAFKA_TOPICS.FLOW_BACKTEST_COMPLETED, - fromBeginning: false, - }); - - await this.consumer.run({ - eachMessage: async ({ message }) => { - if (!message.value) return; - const event: BacktestCompletedEvent = JSON.parse(message.value.toString()); - this.logger.log(`Backtest ${event.status}: ${event.backtestId} (flow: ${event.flowId})`); - for (const listener of this.listeners) { - listener({ - backtestId: event.backtestId, - flowId: event.flowId, - userId: event.userId, - status: event.status, - error: event.error, - }); - } - }, - }); - - this.logger.log('Flows Kafka consumer started — listening for backtest completions'); - } - - async onModuleDestroy() { - await this.consumer.disconnect(); - } - - onBacktestCompleted(listener: (payload: BacktestCompletedPayload) => void) { - this.listeners.push(listener); - } -} diff --git a/apps/api-server/src/flows/flows-kafka.producer.ts b/apps/api-server/src/flows/flows-kafka.producer.ts deleted file mode 100644 index 6977820..0000000 --- a/apps/api-server/src/flows/flows-kafka.producer.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { Kafka, Producer } from 'kafkajs'; -import { KAFKA_TOPICS } from '@coin/kafka-contracts'; -import type { BacktestRequestedEvent } from '@coin/kafka-contracts'; - -@Injectable() -export class FlowsKafkaProducer implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(FlowsKafkaProducer.name); - private kafka: Kafka; - private producer: Producer; - - constructor() { - this.kafka = new Kafka({ - clientId: 'api-server-flows', - brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), - }); - this.producer = this.kafka.producer(); - } - - async onModuleInit() { - await this.producer.connect(); - this.logger.log('Flows Kafka producer connected'); - } - - async onModuleDestroy() { - await this.producer.disconnect(); - } - - async publishBacktestRequested(event: BacktestRequestedEvent) { - await this.producer.send({ - topic: KAFKA_TOPICS.FLOW_BACKTEST_REQUESTED, - messages: [ - { - key: event.flowId, - value: JSON.stringify(event), - }, - ], - }); - this.logger.log(`Published BacktestRequested for backtest ${event.backtestId}`); - } -} diff --git a/apps/api-server/src/flows/flows.controller.ts b/apps/api-server/src/flows/flows.controller.ts deleted file mode 100644 index cbeef25..0000000 --- a/apps/api-server/src/flows/flows.controller.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { - Controller, - Get, - Post, - Patch, - Delete, - Body, - Param, - Query, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import { - ApiTags, - ApiBearerAuth, - ApiOperation, - ApiResponse, - ApiParam, - ApiQuery, -} from '@nestjs/swagger'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; -import { CurrentUser } from '../auth/decorators/current-user.decorator'; -import { - CreateFlowCommand, - UpdateFlowCommand, - DeleteFlowCommand, - ToggleFlowCommand, - RequestBacktestCommand, -} from './commands'; -import { GetFlowsQuery, GetFlowQuery, GetBacktestsQuery, GetBacktestTraceQuery } from './queries'; -import { CreateFlowDto } from './dto/create-flow.dto'; -import { UpdateFlowDto } from './dto/update-flow.dto'; -import { BacktestRequestDto } from './dto/backtest-request.dto'; -import { FlowResponse, BacktestResponse, BacktestTraceListResponse } from './dto/flow-response.dto'; -import type { User } from '@coin/database'; - -@ApiTags('Flows') -@ApiBearerAuth('access-token') -@Controller('flows') -export class FlowsController { - constructor( - private readonly commandBus: CommandBus, - private readonly queryBus: QueryBus, - ) {} - - @Post() - @ApiOperation({ summary: '새 플로우 전략 생성' }) - @ApiResponse({ status: 201, description: '플로우 생성 성공', type: FlowResponse }) - async create(@CurrentUser() user: User, @Body() dto: CreateFlowDto) { - return this.commandBus.execute(new CreateFlowCommand(user.id, dto)); - } - - @Get() - @ApiOperation({ summary: '현재 사용자의 모든 플로우 조회' }) - @ApiResponse({ status: 200, description: '플로우 목록 반환', type: [FlowResponse] }) - async findAll(@CurrentUser() user: User) { - return this.queryBus.execute(new GetFlowsQuery(user.id)); - } - - @Get(':id') - @ApiOperation({ summary: 'ID로 특정 플로우 조회' }) - @ApiResponse({ status: 200, description: '플로우 상세 반환', type: FlowResponse }) - @ApiParam({ name: 'id', description: '플로우 ID' }) - async findOne(@CurrentUser() user: User, @Param('id') id: string) { - return this.queryBus.execute(new GetFlowQuery(user.id, id)); - } - - @Patch(':id') - @ApiOperation({ summary: '플로우 정의/설정 수정' }) - @ApiResponse({ status: 200, description: '플로우 수정 성공', type: FlowResponse }) - @ApiParam({ name: 'id', description: '플로우 ID' }) - async update(@CurrentUser() user: User, @Param('id') id: string, @Body() dto: UpdateFlowDto) { - return this.commandBus.execute(new UpdateFlowCommand(user.id, id, dto)); - } - - @Patch(':id/toggle') - @ApiOperation({ summary: '플로우 활성/비활성 전환' }) - @ApiResponse({ status: 200, description: '플로우 전환 성공' }) - @ApiParam({ name: 'id', description: '플로우 ID' }) - async toggle(@CurrentUser() user: User, @Param('id') id: string) { - return this.commandBus.execute(new ToggleFlowCommand(user.id, id)); - } - - @Delete(':id') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '플로우 영구 삭제' }) - @ApiResponse({ status: 200, description: '플로우 삭제 성공' }) - @ApiParam({ name: 'id', description: '플로우 ID' }) - async remove(@CurrentUser() user: User, @Param('id') id: string) { - return this.commandBus.execute(new DeleteFlowCommand(user.id, id)); - } - - @Post(':id/backtest') - @ApiOperation({ summary: '백테스트 실행 요청 (비동기)' }) - @ApiResponse({ status: 201, description: '백테스트 요청 접수' }) - @ApiParam({ name: 'id', description: '플로우 ID' }) - async requestBacktest( - @CurrentUser() user: User, - @Param('id') id: string, - @Body() dto: BacktestRequestDto, - ) { - return this.commandBus.execute(new RequestBacktestCommand(user.id, id, dto)); - } - - @Get(':id/backtests') - @ApiOperation({ summary: '플로우의 백테스트 결과 목록' }) - @ApiResponse({ status: 200, description: '백테스트 목록', type: [BacktestResponse] }) - @ApiParam({ name: 'id', description: '플로우 ID' }) - async getBacktests(@CurrentUser() user: User, @Param('id') id: string) { - return this.queryBus.execute(new GetBacktestsQuery(user.id, id)); - } - - @Get(':id/backtests/:backtestId/trace') - @ApiOperation({ summary: '백테스트 트레이스 조회 (페이지네이션)' }) - @ApiResponse({ status: 200, description: '트레이스 데이터', type: BacktestTraceListResponse }) - @ApiParam({ name: 'id', description: '플로우 ID' }) - @ApiParam({ name: 'backtestId', description: '백테스트 ID' }) - @ApiQuery({ name: 'from', required: false, description: '시작 시간 (ISO 8601)' }) - @ApiQuery({ name: 'to', required: false, description: '종료 시간 (ISO 8601)' }) - @ApiQuery({ name: 'limit', required: false, description: '최대 결과 수 (기본 100)' }) - @ApiQuery({ name: 'offset', required: false, description: '오프셋' }) - async getBacktestTrace( - @CurrentUser() user: User, - @Param('id') id: string, - @Param('backtestId') backtestId: string, - @Query('from') from?: string, - @Query('to') to?: string, - @Query('limit') limit?: string, - @Query('offset') offset?: string, - ) { - return this.queryBus.execute( - new GetBacktestTraceQuery( - user.id, - id, - backtestId, - from, - to, - limit ? parseInt(limit, 10) : undefined, - offset ? parseInt(offset, 10) : undefined, - ), - ); - } -} diff --git a/apps/api-server/src/flows/flows.module.ts b/apps/api-server/src/flows/flows.module.ts deleted file mode 100644 index df87959..0000000 --- a/apps/api-server/src/flows/flows.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CqrsModule } from '@nestjs/cqrs'; -import { FlowsController } from './flows.controller'; -import { FlowCommandHandlers } from './commands'; -import { FlowQueryHandlers } from './queries'; -import { FlowsKafkaProducer } from './flows-kafka.producer'; -import { FlowsKafkaConsumer } from './flows-kafka.consumer'; - -@Module({ - imports: [CqrsModule], - controllers: [FlowsController], - providers: [...FlowCommandHandlers, ...FlowQueryHandlers, FlowsKafkaProducer, FlowsKafkaConsumer], - exports: [FlowsKafkaProducer, FlowsKafkaConsumer], -}) -export class FlowsModule {} diff --git a/apps/api-server/src/flows/queries/get-backtest-trace.handler.ts b/apps/api-server/src/flows/queries/get-backtest-trace.handler.ts deleted file mode 100644 index 3a6dbf4..0000000 --- a/apps/api-server/src/flows/queries/get-backtest-trace.handler.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { QueryHandler, IQueryHandler } from '@nestjs/cqrs'; -import { NotFoundException } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; -import { GetBacktestTraceQuery } from './get-backtest-trace.query'; - -@QueryHandler(GetBacktestTraceQuery) -export class GetBacktestTraceHandler implements IQueryHandler { - constructor(private readonly prisma: PrismaService) {} - - async execute(query: GetBacktestTraceQuery) { - // Verify ownership via flow - const flow = await this.prisma.flow.findFirst({ - where: { id: query.flowId, userId: query.userId }, - select: { id: true }, - }); - if (!flow) throw new NotFoundException('Flow not found'); - - const backtest = await this.prisma.backtest.findFirst({ - where: { id: query.backtestId, flowId: query.flowId }, - select: { id: true }, - }); - if (!backtest) throw new NotFoundException('Backtest not found'); - - const timestampFilter: Record = {}; - if (query.from) timestampFilter.gte = new Date(query.from); - if (query.to) timestampFilter.lte = new Date(query.to); - - const where = { - backtestId: query.backtestId, - ...(Object.keys(timestampFilter).length > 0 && { - timestamp: timestampFilter, - }), - }; - - const [items, total] = await Promise.all([ - this.prisma.backtestTrace.findMany({ - where, - orderBy: { timestamp: 'asc' }, - take: query.limit || 100, - skip: query.offset || 0, - }), - this.prisma.backtestTrace.count({ where }), - ]); - - return { items, total }; - } -} diff --git a/apps/api-server/src/flows/queries/get-backtest-trace.query.ts b/apps/api-server/src/flows/queries/get-backtest-trace.query.ts deleted file mode 100644 index 5f040a8..0000000 --- a/apps/api-server/src/flows/queries/get-backtest-trace.query.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class GetBacktestTraceQuery { - constructor( - public readonly userId: string, - public readonly flowId: string, - public readonly backtestId: string, - public readonly from?: string, - public readonly to?: string, - public readonly limit?: number, - public readonly offset?: number, - ) {} -} diff --git a/apps/api-server/src/flows/queries/get-backtests.handler.ts b/apps/api-server/src/flows/queries/get-backtests.handler.ts deleted file mode 100644 index abc2a71..0000000 --- a/apps/api-server/src/flows/queries/get-backtests.handler.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { QueryHandler, IQueryHandler } from '@nestjs/cqrs'; -import { NotFoundException } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; -import { GetBacktestsQuery } from './get-backtests.query'; - -@QueryHandler(GetBacktestsQuery) -export class GetBacktestsHandler implements IQueryHandler { - constructor(private readonly prisma: PrismaService) {} - - async execute(query: GetBacktestsQuery) { - const flow = await this.prisma.flow.findFirst({ - where: { id: query.flowId, userId: query.userId }, - select: { id: true }, - }); - if (!flow) throw new NotFoundException('Flow not found'); - - return this.prisma.backtest.findMany({ - where: { flowId: query.flowId }, - orderBy: { createdAt: 'desc' }, - }); - } -} diff --git a/apps/api-server/src/flows/queries/get-backtests.query.ts b/apps/api-server/src/flows/queries/get-backtests.query.ts deleted file mode 100644 index 0396097..0000000 --- a/apps/api-server/src/flows/queries/get-backtests.query.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class GetBacktestsQuery { - constructor( - public readonly userId: string, - public readonly flowId: string, - ) {} -} diff --git a/apps/api-server/src/flows/queries/get-flow.handler.ts b/apps/api-server/src/flows/queries/get-flow.handler.ts deleted file mode 100644 index 155f5d7..0000000 --- a/apps/api-server/src/flows/queries/get-flow.handler.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { QueryHandler, IQueryHandler } from '@nestjs/cqrs'; -import { NotFoundException } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; -import { GetFlowQuery } from './get-flow.query'; - -@QueryHandler(GetFlowQuery) -export class GetFlowHandler implements IQueryHandler { - constructor(private readonly prisma: PrismaService) {} - - async execute(query: GetFlowQuery) { - const flow = await this.prisma.flow.findFirst({ - where: { id: query.id, userId: query.userId }, - include: { - backtests: { - orderBy: { createdAt: 'desc' }, - select: { - id: true, - startDate: true, - endDate: true, - status: true, - summary: true, - createdAt: true, - }, - }, - }, - }); - if (!flow) throw new NotFoundException('Flow not found'); - return flow; - } -} diff --git a/apps/api-server/src/flows/queries/get-flow.query.ts b/apps/api-server/src/flows/queries/get-flow.query.ts deleted file mode 100644 index 1ddd1cf..0000000 --- a/apps/api-server/src/flows/queries/get-flow.query.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class GetFlowQuery { - constructor( - public readonly userId: string, - public readonly id: string, - ) {} -} diff --git a/apps/api-server/src/flows/queries/get-flows.handler.ts b/apps/api-server/src/flows/queries/get-flows.handler.ts deleted file mode 100644 index 6d0dce8..0000000 --- a/apps/api-server/src/flows/queries/get-flows.handler.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { QueryHandler, IQueryHandler } from '@nestjs/cqrs'; -import { PrismaService } from '../../prisma/prisma.service'; -import { GetFlowsQuery } from './get-flows.query'; - -@QueryHandler(GetFlowsQuery) -export class GetFlowsHandler implements IQueryHandler { - constructor(private readonly prisma: PrismaService) {} - - async execute(query: GetFlowsQuery) { - return this.prisma.flow.findMany({ - where: { userId: query.userId }, - orderBy: { createdAt: 'desc' }, - include: { - backtests: { - orderBy: { createdAt: 'desc' }, - take: 1, - select: { - id: true, - status: true, - summary: true, - createdAt: true, - }, - }, - }, - }); - } -} diff --git a/apps/api-server/src/flows/queries/get-flows.query.ts b/apps/api-server/src/flows/queries/get-flows.query.ts deleted file mode 100644 index 62f72a2..0000000 --- a/apps/api-server/src/flows/queries/get-flows.query.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class GetFlowsQuery { - constructor(public readonly userId: string) {} -} diff --git a/apps/api-server/src/flows/queries/index.ts b/apps/api-server/src/flows/queries/index.ts deleted file mode 100644 index 75b37a0..0000000 --- a/apps/api-server/src/flows/queries/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { GetFlowsQuery } from './get-flows.query'; -export { GetFlowQuery } from './get-flow.query'; -export { GetBacktestsQuery } from './get-backtests.query'; -export { GetBacktestTraceQuery } from './get-backtest-trace.query'; - -import { GetFlowsHandler } from './get-flows.handler'; -import { GetFlowHandler } from './get-flow.handler'; -import { GetBacktestsHandler } from './get-backtests.handler'; -import { GetBacktestTraceHandler } from './get-backtest-trace.handler'; - -export const FlowQueryHandlers = [ - GetFlowsHandler, - GetFlowHandler, - GetBacktestsHandler, - GetBacktestTraceHandler, -]; diff --git a/apps/api-server/src/markets/markets.controller.ts b/apps/api-server/src/markets/markets.controller.ts index 5783068..60f3f8e 100644 --- a/apps/api-server/src/markets/markets.controller.ts +++ b/apps/api-server/src/markets/markets.controller.ts @@ -3,13 +3,11 @@ import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/ import { TickerResponse, ExchangeRateResponse, CandleResponse } from './dto/market-response.dto'; import { MarketsService } from './markets.service'; import { Public } from '../auth/decorators/public.decorator'; -import { UpbitRest, BinanceRest, BybitRest, IExchangeRest } from '@coin/exchange-adapters'; +import { BinanceRest, IExchangeRest } from '@coin/exchange-adapters'; import type { ExchangeId } from '@coin/types'; const REST_ADAPTERS: Record IExchangeRest> = { - upbit: () => new UpbitRest(), binance: () => new BinanceRest(), - bybit: () => new BybitRest(), }; @ApiTags('Markets') @@ -22,7 +20,7 @@ export class MarketsController { @ApiOperation({ summary: '모든 거래소의 캐시된 티커 조회', description: - '모든 거래소(Upbit, Binance, Bybit)의 실시간 티커 데이터를 Redis 캐시에서 조회합니다. 가격, 24시간 변동률, 거래량 등을 포함합니다.', + 'Binance 실시간 티커 데이터를 Redis 캐시에서 조회합니다. 가격, 24시간 변동률, 거래량 등을 포함합니다.', }) @ApiResponse({ status: 200, description: '티커 목록 반환', type: [TickerResponse] }) async getAllTickers() { @@ -49,7 +47,7 @@ export class MarketsController { }) @ApiResponse({ status: 200, description: '캔들 데이터 반환', type: [CandleResponse] }) @ApiResponse({ status: 404, description: '거래소를 찾을 수 없음' }) - @ApiParam({ name: 'exchange', description: '거래소 식별자 (upbit, binance, bybit)' }) + @ApiParam({ name: 'exchange', description: '거래소 식별자 (binance)' }) @ApiParam({ name: 'symbol', description: '트레이딩 심볼 (예: BTC/KRW)' }) @ApiQuery({ name: 'interval', @@ -82,7 +80,7 @@ export class MarketsController { }) @ApiResponse({ status: 200, description: '티커 데이터 반환', type: TickerResponse }) @ApiResponse({ status: 404, description: '티커를 찾을 수 없음' }) - @ApiParam({ name: 'exchange', description: '거래소 식별자 (upbit, binance, bybit)' }) + @ApiParam({ name: 'exchange', description: '거래소 식별자 (binance)' }) @ApiParam({ name: 'symbol', description: '트레이딩 심볼 (예: BTC/KRW)' }) async getTicker(@Param('exchange') exchange: string, @Param('symbol') symbol: string) { const ticker = await this.marketsService.getLatestTicker(exchange, symbol); diff --git a/apps/api-server/src/markets/markets.gateway.ts b/apps/api-server/src/markets/markets.gateway.ts index 94152e2..2fcb426 100644 --- a/apps/api-server/src/markets/markets.gateway.ts +++ b/apps/api-server/src/markets/markets.gateway.ts @@ -9,7 +9,6 @@ import { Logger } from '@nestjs/common'; import { Server, Socket } from 'socket.io'; import { MarketsService } from './markets.service'; import { NotificationsService } from '../notifications/notifications.service'; -import { FlowsKafkaConsumer } from '../flows/flows-kafka.consumer'; @WebSocketGateway({ path: '/ws', @@ -32,7 +31,6 @@ export class MarketsGateway implements OnGatewayInit, OnGatewayConnection, OnGat constructor( private readonly marketsService: MarketsService, private readonly notificationsService: NotificationsService, - private readonly flowsKafkaConsumer: FlowsKafkaConsumer, ) {} afterInit() { @@ -51,17 +49,6 @@ export class MarketsGateway implements OnGatewayInit, OnGatewayConnection, OnGat }); }); - this.marketsService.onStrategySignal((payload) => { - this.server.to(`user:${payload.userId}`).emit('strategy:signal', { - strategyId: payload.strategyId, - exchange: payload.exchange, - symbol: payload.symbol, - signal: payload.signal, - strategyType: payload.strategyType, - reason: payload.reason, - }); - }); - this.notificationsService.onNotification((payload) => { this.server.to(`user:${payload.userId}`).emit('notification:received', { type: payload.type, @@ -70,15 +57,6 @@ export class MarketsGateway implements OnGatewayInit, OnGatewayConnection, OnGat }); }); - this.flowsKafkaConsumer.onBacktestCompleted((payload) => { - this.server.to(`user:${payload.userId}`).emit('backtest:completed', { - backtestId: payload.backtestId, - flowId: payload.flowId, - status: payload.status, - error: payload.error, - }); - }); - this.logger.log('WebSocket Gateway initialized'); } diff --git a/apps/api-server/src/markets/markets.module.ts b/apps/api-server/src/markets/markets.module.ts index e70ae24..10f8d09 100644 --- a/apps/api-server/src/markets/markets.module.ts +++ b/apps/api-server/src/markets/markets.module.ts @@ -4,11 +4,10 @@ import { MarketsService } from './markets.service'; import { MarketsController } from './markets.controller'; import { NotificationsModule } from '../notifications/notifications.module'; import { OrdersModule } from '../orders/orders.module'; -import { FlowsModule } from '../flows/flows.module'; import { OrderLifecycleOrchestrator } from '../orders/sagas/order-lifecycle.orchestrator'; @Module({ - imports: [NotificationsModule, OrdersModule, FlowsModule], + imports: [NotificationsModule, OrdersModule], providers: [MarketsGateway, MarketsService], controllers: [MarketsController], exports: [MarketsService], diff --git a/apps/api-server/src/markets/markets.service.ts b/apps/api-server/src/markets/markets.service.ts index 783dc5d..2e865b5 100644 --- a/apps/api-server/src/markets/markets.service.ts +++ b/apps/api-server/src/markets/markets.service.ts @@ -3,24 +3,10 @@ import { Kafka, Consumer, Producer } from 'kafkajs'; import Redis from 'ioredis'; import { Ticker } from '@coin/types'; import { KAFKA_TOPICS } from '@coin/kafka-contracts'; -import type { - OrderResultEvent, - StrategySignalEvent, - NotificationEvent, -} from '@coin/kafka-contracts'; +import type { OrderResultEvent, NotificationEvent } from '@coin/kafka-contracts'; import { PrismaService } from '../prisma/prisma.service'; import { executePositionUpdateSaga } from '../portfolio/sagas/position-update-steps'; -export interface StrategySignalPayload { - strategyId: string; - userId: string; - exchange: string; - symbol: string; - signal: 'buy' | 'sell'; - strategyType: string; - reason: string; -} - export interface OrderUpdatePayload { userId: string; orderId: string; @@ -37,12 +23,10 @@ export class MarketsService implements OnModuleInit, OnModuleDestroy { private kafka: Kafka; private tickerConsumer: Consumer; private orderConsumer: Consumer; - private strategyConsumer: Consumer; private producer: Producer; private redis: Redis; private tickerListeners: ((ticker: Ticker) => void)[] = []; private orderListeners: ((payload: OrderUpdatePayload) => void)[] = []; - private strategySignalListeners: ((payload: StrategySignalPayload) => void)[] = []; constructor(private readonly prisma: PrismaService) { this.kafka = new Kafka({ @@ -51,7 +35,6 @@ export class MarketsService implements OnModuleInit, OnModuleDestroy { }); this.tickerConsumer = this.kafka.consumer({ groupId: 'api-server-ticker' }); this.orderConsumer = this.kafka.consumer({ groupId: 'api-server-orders' }); - this.strategyConsumer = this.kafka.consumer({ groupId: 'api-server-strategies' }); this.producer = this.kafka.producer(); this.redis = new Redis({ host: process.env.REDIS_HOST || 'localhost', @@ -102,43 +85,12 @@ export class MarketsService implements OnModuleInit, OnModuleDestroy { }, }); - // Strategy signal consumer - await this.strategyConsumer.connect(); - await this.strategyConsumer.subscribe({ - topic: KAFKA_TOPICS.TRADING_STRATEGY_SIGNAL, - fromBeginning: false, - }); - - await this.strategyConsumer.run({ - eachMessage: async ({ message }) => { - if (!message.value) return; - try { - const event: StrategySignalEvent = JSON.parse(message.value.toString()); - const payload: StrategySignalPayload = { - strategyId: event.strategyId, - userId: event.userId, - exchange: event.exchange, - symbol: event.symbol, - signal: event.signal, - strategyType: event.strategyType, - reason: event.reason, - }; - for (const listener of this.strategySignalListeners) { - listener(payload); - } - } catch (err) { - this.logger.error(`Failed to process strategy signal: ${err}`); - } - }, - }); - - this.logger.log('Kafka consumers started - listening for ticker, order, and strategy updates'); + this.logger.log('Kafka consumers started - listening for ticker and order updates'); } async onModuleDestroy() { await this.tickerConsumer.disconnect(); await this.orderConsumer.disconnect(); - await this.strategyConsumer.disconnect(); await this.producer.disconnect(); this.redis.disconnect(); } @@ -151,10 +103,6 @@ export class MarketsService implements OnModuleInit, OnModuleDestroy { this.orderListeners.push(callback); } - onStrategySignal(callback: (payload: StrategySignalPayload) => void) { - this.strategySignalListeners.push(callback); - } - setOrchestrator(orchestrator: any) { this.orchestrator = orchestrator; } diff --git a/apps/api-server/src/notifications/notifications.service.ts b/apps/api-server/src/notifications/notifications.service.ts index 8445df2..47c840f 100644 --- a/apps/api-server/src/notifications/notifications.service.ts +++ b/apps/api-server/src/notifications/notifications.service.ts @@ -73,9 +73,7 @@ export class NotificationsService implements OnModuleInit, OnModuleDestroy { const shouldNotify = !setting || (type === 'order_filled' && setting.notifyOrders) || - (type === 'order_failed' && setting.notifyOrders) || - (type === 'strategy_signal' && setting.notifySignals) || - (type === 'risk_blocked' && setting.notifyRisks); + (type === 'order_failed' && setting.notifyOrders); if (!shouldNotify) return; diff --git a/apps/api-server/src/orders/commands/cancel-order.handler.ts b/apps/api-server/src/orders/commands/cancel-order.handler.ts index 81395b7..3419549 100644 --- a/apps/api-server/src/orders/commands/cancel-order.handler.ts +++ b/apps/api-server/src/orders/commands/cancel-order.handler.ts @@ -23,7 +23,7 @@ export class CancelOrderHandler implements ICommandHandler { } if (order.mode === 'real' && order.exchangeOrderId) { - const { UpbitRest, BinanceRest, BybitRest } = await import('@coin/exchange-adapters'); + const { BinanceRest } = await import('@coin/exchange-adapters'); const { decrypt } = await import('@coin/utils'); if (order.exchangeKeyId) { @@ -33,10 +33,8 @@ export class CancelOrderHandler implements ICommandHandler { if (key) { const masterKey = process.env.ENCRYPTION_MASTER_KEY; if (masterKey) { - const adapters = { - upbit: UpbitRest, + const adapters: Record = { binance: BinanceRest, - bybit: BybitRest, }; const AdapterClass = adapters[order.exchange as ExchangeId]; const adapter = new AdapterClass(); diff --git a/apps/api-server/src/orders/commands/create-order.handler.test.ts b/apps/api-server/src/orders/commands/create-order.handler.test.ts index ba991db..ec36fbd 100644 --- a/apps/api-server/src/orders/commands/create-order.handler.test.ts +++ b/apps/api-server/src/orders/commands/create-order.handler.test.ts @@ -19,7 +19,7 @@ describe('CreateOrderHandler', () => { const result = await handler.execute( new CreateOrderCommand('user-1', { - exchange: 'upbit', + exchange: 'binance', symbol: 'KRW-BTC', side: 'buy', type: 'market', @@ -36,7 +36,7 @@ describe('CreateOrderHandler', () => { await expect( handler.execute( new CreateOrderCommand('user-1', { - exchange: 'upbit', + exchange: 'binance', symbol: 'KRW-BTC', side: 'buy', type: 'market', @@ -53,7 +53,7 @@ describe('CreateOrderHandler', () => { await expect( handler.execute( new CreateOrderCommand('user-1', { - exchange: 'upbit', + exchange: 'binance', symbol: 'KRW-BTC', side: 'buy', type: 'market', @@ -69,7 +69,7 @@ describe('CreateOrderHandler', () => { await expect( handler.execute( new CreateOrderCommand('user-1', { - exchange: 'upbit', + exchange: 'binance', symbol: 'KRW-BTC', side: 'buy', type: 'limit', @@ -85,7 +85,7 @@ describe('CreateOrderHandler', () => { await handler.execute( new CreateOrderCommand('user-1', { - exchange: 'upbit', + exchange: 'binance', symbol: 'KRW-BTC', side: 'buy', type: 'market', diff --git a/apps/api-server/src/orders/dto/create-order.dto.ts b/apps/api-server/src/orders/dto/create-order.dto.ts index 9eee1de..2de74ee 100644 --- a/apps/api-server/src/orders/dto/create-order.dto.ts +++ b/apps/api-server/src/orders/dto/create-order.dto.ts @@ -5,9 +5,9 @@ export class CreateOrderDto { @ApiProperty({ description: '대상 거래소', example: 'binance', - enum: ['upbit', 'binance', 'bybit'], + enum: ['binance'], }) - @IsIn(['upbit', 'binance', 'bybit']) + @IsIn(['binance']) exchange!: string; @ApiProperty({ description: '트레이딩 심볼', example: 'BTC/USDT' }) diff --git a/apps/api-server/src/orders/queries/get-orders.handler.test.ts b/apps/api-server/src/orders/queries/get-orders.handler.test.ts index f407f22..1c5119b 100644 --- a/apps/api-server/src/orders/queries/get-orders.handler.test.ts +++ b/apps/api-server/src/orders/queries/get-orders.handler.test.ts @@ -41,14 +41,14 @@ describe('GetOrdersHandler', () => { mockPrisma.order.findMany.mockResolvedValue([]); await handler.execute( - new GetOrdersQuery('user-1', undefined, 20, 'filled', 'upbit', 'KRW-BTC', 'paper', 'buy'), + new GetOrdersQuery('user-1', undefined, 20, 'filled', 'binance', 'BTCUSDT', 'paper', 'buy'), ); const where = mockPrisma.order.findMany.mock.calls[0][0].where; expect(where.userId).toBe('user-1'); expect(where.status).toBe('filled'); - expect(where.exchange).toBe('upbit'); - expect(where.symbol).toBe('KRW-BTC'); + expect(where.exchange).toBe('binance'); + expect(where.symbol).toBe('BTCUSDT'); expect(where.mode).toBe('paper'); expect(where.side).toBe('buy'); }); diff --git a/apps/api-server/src/portfolio/portfolio.service.ts b/apps/api-server/src/portfolio/portfolio.service.ts index e4e2ac9..95adfde 100644 --- a/apps/api-server/src/portfolio/portfolio.service.ts +++ b/apps/api-server/src/portfolio/portfolio.service.ts @@ -2,14 +2,12 @@ import { Injectable, Logger } from '@nestjs/common'; import Redis from 'ioredis'; import { PrismaService } from '../prisma/prisma.service'; import { ConfigService } from '@nestjs/config'; -import { UpbitRest, BinanceRest, BybitRest, IExchangeRest } from '@coin/exchange-adapters'; +import { BinanceRest, IExchangeRest } from '@coin/exchange-adapters'; import type { ExchangeId, ExchangeCredentials, Ticker } from '@coin/types'; import { decrypt } from '@coin/utils'; const REST_ADAPTERS: Record IExchangeRest> = { - upbit: () => new UpbitRest(), binance: () => new BinanceRest(), - bybit: () => new BybitRest(), }; interface PortfolioAsset { @@ -22,17 +20,7 @@ interface PortfolioAsset { pnl: number; } -/** - * Parse base currency from exchange-specific symbol format. - * - Upbit: "KRW-BTC" → "BTC" - * - Binance/Bybit: "BTCUSDT" → "BTC" - */ -function parseBaseCurrency(exchange: string, symbol: string): string { - if (exchange === 'upbit') { - const parts = symbol.split('-'); - return parts.length > 1 ? parts[1] : symbol; - } - // Binance / Bybit: remove known quote currencies +function parseBaseCurrency(_exchange: string, symbol: string): string { for (const quote of ['USDT', 'BUSD', 'USD', 'USDC']) { if (symbol.endsWith(quote)) { return symbol.slice(0, -quote.length); @@ -220,8 +208,7 @@ export class PortfolioService { } private async getTickerPrice(exchange: string, currency: string): Promise { - const symbols = - exchange === 'upbit' ? [`KRW-${currency}`] : [`${currency}USDT`, `${currency}USD`]; + const symbols = [`${currency}USDT`, `${currency}USD`]; for (const symbol of symbols) { const key = `ticker:${exchange}:${symbol}`; diff --git a/apps/api-server/src/strategies/commands/create-strategy.command.ts b/apps/api-server/src/strategies/commands/create-strategy.command.ts deleted file mode 100644 index 919e380..0000000 --- a/apps/api-server/src/strategies/commands/create-strategy.command.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { CreateStrategyDto } from '../dto/create-strategy.dto'; - -export class CreateStrategyCommand { - constructor( - public readonly userId: string, - public readonly dto: CreateStrategyDto, - ) {} -} diff --git a/apps/api-server/src/strategies/commands/create-strategy.handler.test.ts b/apps/api-server/src/strategies/commands/create-strategy.handler.test.ts deleted file mode 100644 index 2ceee2d..0000000 --- a/apps/api-server/src/strategies/commands/create-strategy.handler.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { CreateStrategyHandler } from './create-strategy.handler'; -import { CreateStrategyCommand } from './create-strategy.command'; - -const mockPrisma = { - exchangeKey: { findFirst: vi.fn() }, - strategy: { create: vi.fn() }, -}; - -describe('CreateStrategyHandler', () => { - let handler: CreateStrategyHandler; - - beforeEach(() => { - vi.clearAllMocks(); - handler = new CreateStrategyHandler(mockPrisma as never); - }); - - it('페이퍼 전략을 생성해야 한다', async () => { - const created = { id: 'strat-1', name: 'RSI Test', type: 'rsi' }; - mockPrisma.strategy.create.mockResolvedValue(created); - - const result = await handler.execute( - new CreateStrategyCommand('user-1', { - name: 'RSI Test', - type: 'rsi', - exchange: 'upbit', - symbol: 'KRW-BTC', - mode: 'signal', - tradingMode: 'paper', - config: { period: 14 }, - } as never), - ); - - expect(result).toEqual(created); - expect(mockPrisma.strategy.create).toHaveBeenCalled(); - }); - - it('실거래 모드에서는 exchangeKeyId가 필요하다', async () => { - await expect( - handler.execute( - new CreateStrategyCommand('user-1', { - name: 'Test', - type: 'rsi', - exchange: 'upbit', - symbol: 'KRW-BTC', - mode: 'signal', - tradingMode: 'real', - config: {}, - } as never), - ), - ).rejects.toThrow(BadRequestException); - }); - - it('실거래 모드에서 거래소 키가 존재하는지 검증해야 한다', async () => { - mockPrisma.exchangeKey.findFirst.mockResolvedValue(null); - - await expect( - handler.execute( - new CreateStrategyCommand('user-1', { - name: 'Test', - type: 'rsi', - exchange: 'upbit', - symbol: 'KRW-BTC', - mode: 'signal', - tradingMode: 'real', - exchangeKeyId: 'key-1', - config: {}, - } as never), - ), - ).rejects.toThrow(NotFoundException); - }); -}); diff --git a/apps/api-server/src/strategies/commands/create-strategy.handler.ts b/apps/api-server/src/strategies/commands/create-strategy.handler.ts deleted file mode 100644 index 71bef5c..0000000 --- a/apps/api-server/src/strategies/commands/create-strategy.handler.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { PrismaService } from '../../prisma/prisma.service'; -import { CreateStrategyCommand } from './create-strategy.command'; - -@CommandHandler(CreateStrategyCommand) -export class CreateStrategyHandler implements ICommandHandler { - private readonly logger = new Logger(CreateStrategyHandler.name); - - constructor(private readonly prisma: PrismaService) {} - - async execute(command: CreateStrategyCommand): Promise { - const { userId, dto } = command; - - if (dto.tradingMode === 'real' && !dto.exchangeKeyId) { - throw new BadRequestException('exchangeKeyId is required for real trading mode'); - } - - if (dto.tradingMode === 'real' && dto.exchangeKeyId) { - const key = await this.prisma.exchangeKey.findFirst({ - where: { id: dto.exchangeKeyId, userId }, - }); - if (!key) throw new NotFoundException('Exchange key not found'); - } - - const strategy = await this.prisma.strategy.create({ - data: { - userId, - name: dto.name, - type: dto.type, - exchange: dto.exchange, - symbol: dto.symbol, - mode: dto.mode, - tradingMode: dto.tradingMode, - exchangeKeyId: dto.tradingMode === 'real' ? dto.exchangeKeyId : null, - config: dto.config as never, - riskConfig: (dto.riskConfig || {}) as never, - intervalSeconds: dto.intervalSeconds || 60, - candleInterval: dto.candleInterval || '1h', - }, - }); - - this.logger.log(`Strategy created: ${strategy.id} (${dto.name})`); - return strategy; - } -} diff --git a/apps/api-server/src/strategies/commands/delete-strategy.command.ts b/apps/api-server/src/strategies/commands/delete-strategy.command.ts deleted file mode 100644 index 08e7393..0000000 --- a/apps/api-server/src/strategies/commands/delete-strategy.command.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class DeleteStrategyCommand { - constructor( - public readonly userId: string, - public readonly id: string, - ) {} -} diff --git a/apps/api-server/src/strategies/commands/delete-strategy.handler.test.ts b/apps/api-server/src/strategies/commands/delete-strategy.handler.test.ts deleted file mode 100644 index 06e3ed8..0000000 --- a/apps/api-server/src/strategies/commands/delete-strategy.handler.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { NotFoundException } from '@nestjs/common'; -import { DeleteStrategyHandler } from './delete-strategy.handler'; -import { DeleteStrategyCommand } from './delete-strategy.command'; - -const mockPrisma = { - strategy: { findFirst: vi.fn(), delete: vi.fn() }, -}; - -describe('DeleteStrategyHandler', () => { - let handler: DeleteStrategyHandler; - - beforeEach(() => { - vi.clearAllMocks(); - handler = new DeleteStrategyHandler(mockPrisma as never); - }); - - it('전략을 삭제해야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue({ id: 'strat-1' }); - mockPrisma.strategy.delete.mockResolvedValue({ id: 'strat-1' }); - - const result = await handler.execute(new DeleteStrategyCommand('user-1', 'strat-1')); - expect(result).toEqual({ id: 'strat-1', deleted: true }); - }); - - it('전략을 찾을 수 없으면 예외를 던져야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue(null); - - await expect( - handler.execute(new DeleteStrategyCommand('user-1', 'non-existent')), - ).rejects.toThrow(NotFoundException); - }); -}); diff --git a/apps/api-server/src/strategies/commands/delete-strategy.handler.ts b/apps/api-server/src/strategies/commands/delete-strategy.handler.ts deleted file mode 100644 index 6d410f2..0000000 --- a/apps/api-server/src/strategies/commands/delete-strategy.handler.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { PrismaService } from '../../prisma/prisma.service'; -import { DeleteStrategyCommand } from './delete-strategy.command'; - -@CommandHandler(DeleteStrategyCommand) -export class DeleteStrategyHandler implements ICommandHandler { - constructor(private readonly prisma: PrismaService) {} - - async execute(command: DeleteStrategyCommand) { - const { userId, id } = command; - - const strategy = await this.prisma.strategy.findFirst({ - where: { id, userId }, - }); - if (!strategy) throw new NotFoundException('Strategy not found'); - - await this.prisma.strategy.delete({ where: { id } }); - return { id, deleted: true }; - } -} diff --git a/apps/api-server/src/strategies/commands/index.ts b/apps/api-server/src/strategies/commands/index.ts deleted file mode 100644 index ddd1c3a..0000000 --- a/apps/api-server/src/strategies/commands/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CreateStrategyHandler } from './create-strategy.handler'; -import { UpdateStrategyHandler } from './update-strategy.handler'; -import { ToggleStrategyHandler } from './toggle-strategy.handler'; -import { DeleteStrategyHandler } from './delete-strategy.handler'; -import { ReorderStrategiesHandler } from './reorder-strategies.handler'; - -export const StrategyCommandHandlers = [ - CreateStrategyHandler, - UpdateStrategyHandler, - ToggleStrategyHandler, - DeleteStrategyHandler, - ReorderStrategiesHandler, -]; - -export { CreateStrategyCommand } from './create-strategy.command'; -export { UpdateStrategyCommand } from './update-strategy.command'; -export { ToggleStrategyCommand } from './toggle-strategy.command'; -export { DeleteStrategyCommand } from './delete-strategy.command'; -export { ReorderStrategiesCommand } from './reorder-strategies.command'; diff --git a/apps/api-server/src/strategies/commands/reorder-strategies.command.ts b/apps/api-server/src/strategies/commands/reorder-strategies.command.ts deleted file mode 100644 index 3397ead..0000000 --- a/apps/api-server/src/strategies/commands/reorder-strategies.command.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ReorderStrategiesDto } from '../dto/reorder-strategies.dto'; - -export class ReorderStrategiesCommand { - constructor( - public readonly userId: string, - public readonly dto: ReorderStrategiesDto, - ) {} -} diff --git a/apps/api-server/src/strategies/commands/reorder-strategies.handler.test.ts b/apps/api-server/src/strategies/commands/reorder-strategies.handler.test.ts deleted file mode 100644 index 4245d87..0000000 --- a/apps/api-server/src/strategies/commands/reorder-strategies.handler.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BadRequestException } from '@nestjs/common'; -import { ReorderStrategiesHandler } from './reorder-strategies.handler'; -import { ReorderStrategiesCommand } from './reorder-strategies.command'; - -const mockPrisma = { - strategy: { - findMany: vi.fn(), - update: vi.fn(), - }, - $transaction: vi.fn(), -}; - -describe('ReorderStrategiesHandler', () => { - let handler: ReorderStrategiesHandler; - - beforeEach(() => { - vi.clearAllMocks(); - handler = new ReorderStrategiesHandler(mockPrisma as never); - }); - - it('전략 순서를 일괄 업데이트해야 한다', async () => { - mockPrisma.strategy.findMany.mockResolvedValue([{ id: 'strat-1' }, { id: 'strat-2' }]); - mockPrisma.$transaction.mockResolvedValue([]); - - await handler.execute( - new ReorderStrategiesCommand('user-1', { - orders: [ - { id: 'strat-1', order: 0 }, - { id: 'strat-2', order: 1 }, - ], - }), - ); - - expect(mockPrisma.strategy.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: { in: ['strat-1', 'strat-2'] }, userId: 'user-1' } }), - ); - expect(mockPrisma.$transaction).toHaveBeenCalled(); - }); - - it('빈 orders 배열이면 아무 작업도 하지 않아야 한다', async () => { - await handler.execute(new ReorderStrategiesCommand('user-1', { orders: [] })); - - expect(mockPrisma.strategy.findMany).not.toHaveBeenCalled(); - expect(mockPrisma.$transaction).not.toHaveBeenCalled(); - }); - - it('소유하지 않은 전략 ID가 포함되면 예외를 던져야 한다', async () => { - mockPrisma.strategy.findMany.mockResolvedValue([{ id: 'strat-1' }]); - - await expect( - handler.execute( - new ReorderStrategiesCommand('user-1', { - orders: [ - { id: 'strat-1', order: 0 }, - { id: 'strat-99', order: 1 }, - ], - }), - ), - ).rejects.toThrow(BadRequestException); - }); -}); diff --git a/apps/api-server/src/strategies/commands/reorder-strategies.handler.ts b/apps/api-server/src/strategies/commands/reorder-strategies.handler.ts deleted file mode 100644 index 4adca4b..0000000 --- a/apps/api-server/src/strategies/commands/reorder-strategies.handler.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { BadRequestException, Logger } from '@nestjs/common'; -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { PrismaService } from '../../prisma/prisma.service'; -import { ReorderStrategiesCommand } from './reorder-strategies.command'; - -@CommandHandler(ReorderStrategiesCommand) -export class ReorderStrategiesHandler implements ICommandHandler { - private readonly logger = new Logger(ReorderStrategiesHandler.name); - - constructor(private readonly prisma: PrismaService) {} - - async execute(command: ReorderStrategiesCommand): Promise { - const { userId, dto } = command; - - if (!dto.orders.length) return; - - const ids = dto.orders.map((o) => o.id); - const owned = await this.prisma.strategy.findMany({ - where: { id: { in: ids }, userId }, - select: { id: true }, - }); - - if (owned.length !== ids.length) { - throw new BadRequestException('One or more strategy IDs are invalid or not owned by user'); - } - - await this.prisma.$transaction( - dto.orders.map(({ id, order }) => - this.prisma.strategy.update({ where: { id }, data: { order } }), - ), - ); - - this.logger.log(`Reordered ${dto.orders.length} strategies for user ${userId}`); - } -} diff --git a/apps/api-server/src/strategies/commands/toggle-strategy.command.ts b/apps/api-server/src/strategies/commands/toggle-strategy.command.ts deleted file mode 100644 index 00b212f..0000000 --- a/apps/api-server/src/strategies/commands/toggle-strategy.command.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class ToggleStrategyCommand { - constructor( - public readonly userId: string, - public readonly id: string, - ) {} -} diff --git a/apps/api-server/src/strategies/commands/toggle-strategy.handler.test.ts b/apps/api-server/src/strategies/commands/toggle-strategy.handler.test.ts deleted file mode 100644 index dc2b1b1..0000000 --- a/apps/api-server/src/strategies/commands/toggle-strategy.handler.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { NotFoundException } from '@nestjs/common'; -import { ToggleStrategyHandler } from './toggle-strategy.handler'; -import { ToggleStrategyCommand } from './toggle-strategy.command'; - -const mockPrisma = { - strategy: { findFirst: vi.fn(), update: vi.fn() }, -}; - -describe('ToggleStrategyHandler', () => { - let handler: ToggleStrategyHandler; - - beforeEach(() => { - vi.clearAllMocks(); - handler = new ToggleStrategyHandler(mockPrisma as never); - }); - - it('enabled를 false에서 true로 토글해야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue({ id: 'strat-1', enabled: false }); - mockPrisma.strategy.update.mockResolvedValue({ id: 'strat-1', enabled: true }); - - const result = await handler.execute(new ToggleStrategyCommand('user-1', 'strat-1')); - expect(result).toEqual({ id: 'strat-1', enabled: true }); - expect(mockPrisma.strategy.update).toHaveBeenCalledWith( - expect.objectContaining({ data: { enabled: true } }), - ); - }); - - it('enabled를 true에서 false로 토글해야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue({ id: 'strat-1', enabled: true }); - mockPrisma.strategy.update.mockResolvedValue({ id: 'strat-1', enabled: false }); - - const result = await handler.execute(new ToggleStrategyCommand('user-1', 'strat-1')); - expect(result).toEqual({ id: 'strat-1', enabled: false }); - }); - - it('전략을 찾을 수 없으면 예외를 던져야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue(null); - - await expect( - handler.execute(new ToggleStrategyCommand('user-1', 'non-existent')), - ).rejects.toThrow(NotFoundException); - }); -}); diff --git a/apps/api-server/src/strategies/commands/toggle-strategy.handler.ts b/apps/api-server/src/strategies/commands/toggle-strategy.handler.ts deleted file mode 100644 index 8bb5e3d..0000000 --- a/apps/api-server/src/strategies/commands/toggle-strategy.handler.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Logger, NotFoundException } from '@nestjs/common'; -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { PrismaService } from '../../prisma/prisma.service'; -import { ToggleStrategyCommand } from './toggle-strategy.command'; - -@CommandHandler(ToggleStrategyCommand) -export class ToggleStrategyHandler implements ICommandHandler { - private readonly logger = new Logger(ToggleStrategyHandler.name); - - constructor(private readonly prisma: PrismaService) {} - - async execute(command: ToggleStrategyCommand) { - const { userId, id } = command; - - const strategy = await this.prisma.strategy.findFirst({ - where: { id, userId }, - }); - if (!strategy) throw new NotFoundException('Strategy not found'); - - const updated = await this.prisma.strategy.update({ - where: { id }, - data: { enabled: !strategy.enabled }, - }); - - this.logger.log(`Strategy ${id} ${updated.enabled ? 'enabled' : 'disabled'}`); - return { id, enabled: updated.enabled }; - } -} diff --git a/apps/api-server/src/strategies/commands/update-strategy.command.ts b/apps/api-server/src/strategies/commands/update-strategy.command.ts deleted file mode 100644 index cbec310..0000000 --- a/apps/api-server/src/strategies/commands/update-strategy.command.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { UpdateStrategyDto } from '../dto/update-strategy.dto'; - -export class UpdateStrategyCommand { - constructor( - public readonly userId: string, - public readonly id: string, - public readonly dto: UpdateStrategyDto, - ) {} -} diff --git a/apps/api-server/src/strategies/commands/update-strategy.handler.test.ts b/apps/api-server/src/strategies/commands/update-strategy.handler.test.ts deleted file mode 100644 index a6ea45f..0000000 --- a/apps/api-server/src/strategies/commands/update-strategy.handler.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { NotFoundException } from '@nestjs/common'; -import { UpdateStrategyHandler } from './update-strategy.handler'; -import { UpdateStrategyCommand } from './update-strategy.command'; - -const mockPrisma = { - strategy: { findFirst: vi.fn(), update: vi.fn() }, - exchangeKey: { findFirst: vi.fn() }, -}; - -describe('UpdateStrategyHandler', () => { - let handler: UpdateStrategyHandler; - - beforeEach(() => { - vi.clearAllMocks(); - handler = new UpdateStrategyHandler(mockPrisma as never); - }); - - it('전략 필드를 업데이트해야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue({ id: 'strat-1' }); - mockPrisma.strategy.update.mockResolvedValue({ id: 'strat-1', name: 'Updated' }); - - const result = await handler.execute( - new UpdateStrategyCommand('user-1', 'strat-1', { name: 'Updated' } as never), - ); - - expect(result).toEqual({ id: 'strat-1', name: 'Updated' }); - }); - - it('전략을 찾을 수 없으면 예외를 던져야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue(null); - - await expect( - handler.execute(new UpdateStrategyCommand('user-1', 'non-existent', {} as never)), - ).rejects.toThrow(NotFoundException); - }); -}); diff --git a/apps/api-server/src/strategies/commands/update-strategy.handler.ts b/apps/api-server/src/strategies/commands/update-strategy.handler.ts deleted file mode 100644 index 06bbcf0..0000000 --- a/apps/api-server/src/strategies/commands/update-strategy.handler.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { PrismaService } from '../../prisma/prisma.service'; -import { UpdateStrategyCommand } from './update-strategy.command'; - -@CommandHandler(UpdateStrategyCommand) -export class UpdateStrategyHandler implements ICommandHandler { - constructor(private readonly prisma: PrismaService) {} - - async execute(command: UpdateStrategyCommand): Promise { - const { userId, id, dto } = command; - - const strategy = await this.prisma.strategy.findFirst({ - where: { id, userId }, - }); - if (!strategy) throw new NotFoundException('Strategy not found'); - - if (dto.tradingMode === 'real' && dto.exchangeKeyId) { - const key = await this.prisma.exchangeKey.findFirst({ - where: { id: dto.exchangeKeyId, userId }, - }); - if (!key) throw new NotFoundException('Exchange key not found'); - } - - const data: Record = {}; - if (dto.name !== undefined) data.name = dto.name; - if (dto.mode !== undefined) data.mode = dto.mode; - if (dto.tradingMode !== undefined) data.tradingMode = dto.tradingMode; - if (dto.exchangeKeyId !== undefined) data.exchangeKeyId = dto.exchangeKeyId; - if (dto.config !== undefined) data.config = dto.config; - if (dto.riskConfig !== undefined) data.riskConfig = dto.riskConfig; - if (dto.intervalSeconds !== undefined) data.intervalSeconds = dto.intervalSeconds; - if (dto.candleInterval !== undefined) data.candleInterval = dto.candleInterval; - - return this.prisma.strategy.update({ - where: { id }, - data, - }); - } -} diff --git a/apps/api-server/src/strategies/dto/create-strategy.dto.ts b/apps/api-server/src/strategies/dto/create-strategy.dto.ts deleted file mode 100644 index a343c7f..0000000 --- a/apps/api-server/src/strategies/dto/create-strategy.dto.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { IsString, IsIn, IsOptional, IsObject, IsInt, Min } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class CreateStrategyDto { - @ApiProperty({ description: '전략 이름', example: 'BTC RSI Oversold Strategy' }) - @IsString() - name!: string; - - @ApiProperty({ description: '전략 유형', example: 'rsi', enum: ['rsi', 'macd', 'bollinger'] }) - @IsIn(['rsi', 'macd', 'bollinger']) - type!: string; - - @ApiProperty({ - description: '대상 거래소', - example: 'binance', - enum: ['upbit', 'binance', 'bybit'], - }) - @IsIn(['upbit', 'binance', 'bybit']) - exchange!: string; - - @ApiProperty({ description: '트레이딩 심볼', example: 'BTC/USDT' }) - @IsString() - symbol!: string; - - @ApiProperty({ description: '실행 모드', example: 'auto', enum: ['auto', 'signal'] }) - @IsIn(['auto', 'signal']) - mode!: string; - - @ApiProperty({ - description: '거래 모드 (모의 또는 실전)', - example: 'paper', - enum: ['paper', 'real'], - }) - @IsIn(['paper', 'real']) - tradingMode!: string; - - @ApiPropertyOptional({ - description: '실전 거래용 거래소 API 키 ID', - example: '550e8400-e29b-41d4-a716-446655440000', - }) - @IsOptional() - @IsString() - exchangeKeyId?: string; - - @ApiProperty({ - description: '전략별 설정 파라미터', - example: { period: 14, overbought: 70, oversold: 30 }, - }) - @IsObject() - config!: Record; - - @ApiPropertyOptional({ - description: '리스크 관리 설정', - example: { stopLossPercent: 3, takeProfitPercent: 5, maxPositionSize: 0.1 }, - }) - @IsOptional() - @IsObject() - riskConfig?: Record; - - @ApiPropertyOptional({ - description: '전략 평가 간격 (초 단위, 최소 10)', - example: 60, - }) - @IsOptional() - @IsInt() - @Min(10) - intervalSeconds?: number; - - @ApiPropertyOptional({ - description: '분석용 캔들 간격', - example: '15m', - enum: ['1m', '5m', '15m', '1h', '4h', '1d'], - }) - @IsOptional() - @IsIn(['1m', '5m', '15m', '1h', '4h', '1d']) - candleInterval?: string; -} diff --git a/apps/api-server/src/strategies/dto/reorder-strategies.dto.ts b/apps/api-server/src/strategies/dto/reorder-strategies.dto.ts deleted file mode 100644 index 0f11d92..0000000 --- a/apps/api-server/src/strategies/dto/reorder-strategies.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsArray, IsInt, IsString, Min, ValidateNested } from 'class-validator'; - -export class StrategyOrderItem { - @ApiProperty({ description: '전략 ID' }) - @IsString() - id!: string; - - @ApiProperty({ description: '새 순서 값 (0-based)' }) - @IsInt() - @Min(0) - order!: number; -} - -export class ReorderStrategiesDto { - @ApiProperty({ description: '순서 업데이트할 전략 목록', type: [StrategyOrderItem] }) - @IsArray() - @ValidateNested({ each: true }) - @Type(() => StrategyOrderItem) - orders!: StrategyOrderItem[]; -} diff --git a/apps/api-server/src/strategies/dto/strategy-response.dto.ts b/apps/api-server/src/strategies/dto/strategy-response.dto.ts deleted file mode 100644 index 6f9e85d..0000000 --- a/apps/api-server/src/strategies/dto/strategy-response.dto.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class StrategyResponse { - @ApiProperty({ description: '전략 ID' }) - id!: string; - - @ApiProperty({ description: '전략 이름' }) - name!: string; - - @ApiProperty({ description: '전략 유형 (rsi/macd/bollinger)' }) - type!: string; - - @ApiProperty({ description: '거래소' }) - exchange!: string; - - @ApiProperty({ description: '심볼' }) - symbol!: string; - - @ApiProperty({ description: '실행 모드 (auto/signal)' }) - mode!: string; - - @ApiProperty({ description: '거래 모드 (paper/real)' }) - tradingMode!: string; - - @ApiProperty({ description: '활성화 상태' }) - enabled!: boolean; - - @ApiProperty({ description: '전략 설정' }) - config!: object; - - @ApiProperty({ description: '리스크 설정' }) - riskConfig!: object; - - @ApiProperty({ description: '실행 간격 (초)' }) - intervalSeconds!: number; - - @ApiProperty({ description: '캔들 간격' }) - candleInterval!: string; - - @ApiProperty({ description: '표시 순서' }) - order!: number; - - @ApiProperty({ description: '생성일시' }) - createdAt!: string; - - @ApiProperty({ description: '수정일시' }) - updatedAt!: string; -} - -class DailyPnlItem { - @ApiProperty({ description: '날짜', example: '2026-03-28' }) - date!: string; - - @ApiProperty({ description: '누적 손익' }) - pnl!: number; -} - -export class StrategyPerformanceResponse { - @ApiProperty({ description: '총 거래 수' }) - totalTrades!: number; - - @ApiProperty({ description: '매수 횟수' }) - buyTrades!: number; - - @ApiProperty({ description: '매도 횟수' }) - sellTrades!: number; - - @ApiProperty({ description: '수익 거래 수' }) - wins!: number; - - @ApiProperty({ description: '손실 거래 수' }) - losses!: number; - - @ApiProperty({ description: '승률 (%)' }) - winRate!: number; - - @ApiProperty({ description: '실현 손익' }) - realizedPnl!: number; - - @ApiProperty({ description: '일별 누적 P&L', type: [DailyPnlItem] }) - dailyPnl!: DailyPnlItem[]; -} - -export class StrategySignalResponse { - @ApiProperty({ description: '시그널 (buy/sell)' }) - signal!: string; - - @ApiProperty({ description: '액션 유형' }) - action!: string; - - @ApiProperty({ description: '시그널 발생 가격' }) - price!: number; - - @ApiProperty({ description: '발생 일시' }) - createdAt!: string; -} - -export class StrategyLogResponse { - @ApiProperty({ description: '로그 ID' }) - id!: string; - - @ApiProperty({ description: '액션' }) - action!: string; - - @ApiPropertyOptional({ description: '시그널' }) - signal!: string | null; - - @ApiProperty({ description: '상세 정보' }) - details!: object; - - @ApiProperty({ description: '생성일시' }) - createdAt!: string; -} - -export class StrategyLogListResponse { - @ApiProperty({ description: '로그 목록', type: [StrategyLogResponse] }) - items!: StrategyLogResponse[]; - - @ApiPropertyOptional({ description: '다음 페이지 커서' }) - nextCursor!: string | null; -} diff --git a/apps/api-server/src/strategies/dto/update-strategy.dto.ts b/apps/api-server/src/strategies/dto/update-strategy.dto.ts deleted file mode 100644 index eddd1d1..0000000 --- a/apps/api-server/src/strategies/dto/update-strategy.dto.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { IsString, IsIn, IsOptional, IsObject, IsInt, Min } from 'class-validator'; -import { ApiPropertyOptional } from '@nestjs/swagger'; - -export class UpdateStrategyDto { - @ApiPropertyOptional({ description: '전략 이름', example: 'BTC RSI Oversold Strategy v2' }) - @IsOptional() - @IsString() - name?: string; - - @ApiPropertyOptional({ - description: '실행 모드', - example: 'signal', - enum: ['auto', 'signal'], - }) - @IsOptional() - @IsIn(['auto', 'signal']) - mode?: string; - - @ApiPropertyOptional({ - description: '거래 모드 (모의 또는 실전)', - example: 'real', - enum: ['paper', 'real'], - }) - @IsOptional() - @IsIn(['paper', 'real']) - tradingMode?: string; - - @ApiPropertyOptional({ - description: '실전 거래용 거래소 API 키 ID', - example: '550e8400-e29b-41d4-a716-446655440000', - }) - @IsOptional() - @IsString() - exchangeKeyId?: string; - - @ApiPropertyOptional({ - description: '전략별 설정 파라미터', - example: { period: 14, overbought: 75, oversold: 25 }, - }) - @IsOptional() - @IsObject() - config?: Record; - - @ApiPropertyOptional({ - description: '리스크 관리 설정', - example: { stopLossPercent: 2, takeProfitPercent: 6, maxPositionSize: 0.05 }, - }) - @IsOptional() - @IsObject() - riskConfig?: Record; - - @ApiPropertyOptional({ - description: '전략 평가 간격 (초 단위, 최소 10)', - example: 120, - }) - @IsOptional() - @IsInt() - @Min(10) - intervalSeconds?: number; - - @ApiPropertyOptional({ - description: '분석용 캔들 간격', - example: '1h', - enum: ['1m', '5m', '15m', '1h', '4h', '1d'], - }) - @IsOptional() - @IsIn(['1m', '5m', '15m', '1h', '4h', '1d']) - candleInterval?: string; -} diff --git a/apps/api-server/src/strategies/queries/get-strategies.handler.test.ts b/apps/api-server/src/strategies/queries/get-strategies.handler.test.ts deleted file mode 100644 index 773137f..0000000 --- a/apps/api-server/src/strategies/queries/get-strategies.handler.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { GetStrategiesHandler } from './get-strategies.handler'; -import { GetStrategiesQuery } from './get-strategies.query'; - -const mockPrisma = { strategy: { findMany: vi.fn() } }; - -describe('GetStrategiesHandler', () => { - let handler: GetStrategiesHandler; - - beforeEach(() => { - vi.clearAllMocks(); - handler = new GetStrategiesHandler(mockPrisma as never); - }); - - it('사용자의 모든 전략을 반환해야 한다', async () => { - const strategies = [ - { id: 'strat-1', name: 'RSI', type: 'rsi' }, - { id: 'strat-2', name: 'MACD', type: 'macd' }, - ]; - mockPrisma.strategy.findMany.mockResolvedValue(strategies); - - const result = await handler.execute(new GetStrategiesQuery('user-1')); - expect(result).toEqual(strategies); - expect(mockPrisma.strategy.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { userId: 'user-1' } }), - ); - }); -}); diff --git a/apps/api-server/src/strategies/queries/get-strategies.handler.ts b/apps/api-server/src/strategies/queries/get-strategies.handler.ts deleted file mode 100644 index 386fda8..0000000 --- a/apps/api-server/src/strategies/queries/get-strategies.handler.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { PrismaService } from '../../prisma/prisma.service'; -import { GetStrategiesQuery } from './get-strategies.query'; - -@QueryHandler(GetStrategiesQuery) -export class GetStrategiesHandler implements IQueryHandler { - constructor(private readonly prisma: PrismaService) {} - - async execute(query: GetStrategiesQuery): Promise { - return this.prisma.strategy.findMany({ - where: { userId: query.userId }, - orderBy: [{ order: 'asc' }, { createdAt: 'asc' }], - }); - } -} diff --git a/apps/api-server/src/strategies/queries/get-strategies.query.ts b/apps/api-server/src/strategies/queries/get-strategies.query.ts deleted file mode 100644 index 0eaffdc..0000000 --- a/apps/api-server/src/strategies/queries/get-strategies.query.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class GetStrategiesQuery { - constructor(public readonly userId: string) {} -} diff --git a/apps/api-server/src/strategies/queries/get-strategy-logs.handler.test.ts b/apps/api-server/src/strategies/queries/get-strategy-logs.handler.test.ts deleted file mode 100644 index 4f7e18d..0000000 --- a/apps/api-server/src/strategies/queries/get-strategy-logs.handler.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { NotFoundException } from '@nestjs/common'; -import { GetStrategyLogsHandler } from './get-strategy-logs.handler'; -import { GetStrategyLogsQuery } from './get-strategy-logs.query'; - -const mockPrisma = { - strategy: { findFirst: vi.fn() }, - strategyLog: { findMany: vi.fn() }, -}; - -describe('GetStrategyLogsHandler', () => { - let handler: GetStrategyLogsHandler; - - beforeEach(() => { - vi.clearAllMocks(); - handler = new GetStrategyLogsHandler(mockPrisma as never); - }); - - it('페이지네이션으로 로그를 반환해야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue({ id: 'strat-1' }); - mockPrisma.strategyLog.findMany.mockResolvedValue([ - { id: 'log-1', action: 'evaluate', createdAt: new Date('2025-01-01') }, - ]); - - const result = await handler.execute(new GetStrategyLogsQuery('user-1', 'strat-1')); - expect(result.items).toHaveLength(1); - expect(result.nextCursor).toBeNull(); - }); - - it('전략을 찾을 수 없으면 예외를 던져야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue(null); - - await expect( - handler.execute(new GetStrategyLogsQuery('user-1', 'non-existent')), - ).rejects.toThrow(NotFoundException); - }); - - it('action과 signal 필터를 적용해야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue({ id: 'strat-1' }); - mockPrisma.strategyLog.findMany.mockResolvedValue([]); - - await handler.execute( - new GetStrategyLogsQuery('user-1', 'strat-1', undefined, 20, 'signal_generated', 'buy'), - ); - - const where = mockPrisma.strategyLog.findMany.mock.calls[0][0].where; - expect(where.action).toBe('signal_generated'); - expect(where.signal).toBe('buy'); - }); -}); diff --git a/apps/api-server/src/strategies/queries/get-strategy-logs.handler.ts b/apps/api-server/src/strategies/queries/get-strategy-logs.handler.ts deleted file mode 100644 index 6f56fc5..0000000 --- a/apps/api-server/src/strategies/queries/get-strategy-logs.handler.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { PrismaService } from '../../prisma/prisma.service'; -import { GetStrategyLogsQuery } from './get-strategy-logs.query'; - -@QueryHandler(GetStrategyLogsQuery) -export class GetStrategyLogsHandler implements IQueryHandler { - constructor(private readonly prisma: PrismaService) {} - - async execute( - query: GetStrategyLogsQuery, - ): Promise<{ items: unknown[]; nextCursor: string | null }> { - const { userId, strategyId, cursor, limit, action, signal } = query; - - const strategy = await this.prisma.strategy.findFirst({ - where: { id: strategyId, userId }, - }); - if (!strategy) throw new NotFoundException('Strategy not found'); - - const logs = await this.prisma.strategyLog.findMany({ - where: { - strategyId, - ...(cursor ? { createdAt: { lt: new Date(cursor) } } : {}), - ...(action ? { action } : {}), - ...(signal ? { signal } : {}), - }, - orderBy: { createdAt: 'desc' }, - take: limit + 1, - }); - - const hasMore = logs.length > limit; - const items = hasMore ? logs.slice(0, limit) : logs; - const nextCursor = hasMore ? items[items.length - 1].createdAt.toISOString() : null; - - return { items, nextCursor }; - } -} diff --git a/apps/api-server/src/strategies/queries/get-strategy-logs.query.ts b/apps/api-server/src/strategies/queries/get-strategy-logs.query.ts deleted file mode 100644 index f464c04..0000000 --- a/apps/api-server/src/strategies/queries/get-strategy-logs.query.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class GetStrategyLogsQuery { - constructor( - public readonly userId: string, - public readonly strategyId: string, - public readonly cursor?: string, - public readonly limit: number = 20, - public readonly action?: string, - public readonly signal?: string, - ) {} -} diff --git a/apps/api-server/src/strategies/queries/get-strategy-performance.handler.test.ts b/apps/api-server/src/strategies/queries/get-strategy-performance.handler.test.ts deleted file mode 100644 index cae229d..0000000 --- a/apps/api-server/src/strategies/queries/get-strategy-performance.handler.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { NotFoundException } from '@nestjs/common'; -import { GetStrategyPerformanceHandler } from './get-strategy-performance.handler'; -import { GetStrategyPerformanceQuery } from './get-strategy-performance.query'; - -const mockPrisma = { - strategy: { findFirst: vi.fn() }, - strategyLog: { findMany: vi.fn() }, - order: { findMany: vi.fn() }, -}; - -describe('GetStrategyPerformanceHandler', () => { - let handler: GetStrategyPerformanceHandler; - - beforeEach(() => { - vi.clearAllMocks(); - handler = new GetStrategyPerformanceHandler(mockPrisma as never); - }); - - it('전략을 찾을 수 없으면 예외를 던져야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue(null); - - await expect( - handler.execute(new GetStrategyPerformanceQuery('user-1', 'non-existent')), - ).rejects.toThrow(NotFoundException); - }); - - it('로그가 없으면 0 메트릭을 반환해야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue({ id: 'strat-1', config: {} }); - mockPrisma.strategyLog.findMany.mockResolvedValue([]); - - const result = await handler.execute(new GetStrategyPerformanceQuery('user-1', 'strat-1')); - expect(result.totalTrades).toBe(0); - expect(result.winRate).toBe(0); - expect(result.realizedPnl).toBe(0); - }); - - it('주문 로그로부터 성과를 계산해야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue({ id: 'strat-1', config: {} }); - mockPrisma.strategyLog.findMany.mockResolvedValue([ - { action: 'order_placed', details: { orderId: 'o1' }, createdAt: new Date('2025-01-01') }, - { action: 'order_placed', details: { orderId: 'o2' }, createdAt: new Date('2025-01-02') }, - ]); - mockPrisma.order.findMany.mockResolvedValue([ - { - id: 'o1', - side: 'buy', - filledPrice: '100', - filledQuantity: '1', - fee: '0.1', - status: 'filled', - createdAt: new Date('2025-01-01'), - }, - { - id: 'o2', - side: 'sell', - filledPrice: '110', - filledQuantity: '1', - fee: '0.1', - status: 'filled', - createdAt: new Date('2025-01-02'), - }, - ]); - - const result = await handler.execute(new GetStrategyPerformanceQuery('user-1', 'strat-1')); - expect(result.totalTrades).toBeGreaterThan(0); - expect(result.realizedPnl).toBeGreaterThan(0); - }); -}); diff --git a/apps/api-server/src/strategies/queries/get-strategy-performance.handler.ts b/apps/api-server/src/strategies/queries/get-strategy-performance.handler.ts deleted file mode 100644 index 1c6eb8d..0000000 --- a/apps/api-server/src/strategies/queries/get-strategy-performance.handler.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { PrismaService } from '../../prisma/prisma.service'; -import { GetStrategyPerformanceQuery } from './get-strategy-performance.query'; - -interface PerformanceResult { - totalTrades: number; - buyTrades: number; - sellTrades: number; - wins: number; - losses: number; - winRate: number; - realizedPnl: number; - dailyPnl: Array<{ date: string; pnl: number }>; -} - -@QueryHandler(GetStrategyPerformanceQuery) -export class GetStrategyPerformanceHandler implements IQueryHandler { - constructor(private readonly prisma: PrismaService) {} - - async execute(query: GetStrategyPerformanceQuery): Promise { - const { userId, strategyId } = query; - - const strategy = await this.prisma.strategy.findFirst({ - where: { id: strategyId, userId }, - }); - if (!strategy) throw new NotFoundException('Strategy not found'); - - // Try auto mode first (real orders) - const orderLogs = await this.prisma.strategyLog.findMany({ - where: { strategyId, action: 'order_placed' }, - orderBy: { createdAt: 'asc' }, - }); - - if (orderLogs.length > 0) { - return this.calculateFromOrders(orderLogs); - } - - // Fall back to signal mode (simulate from signal_generated logs) - const signalLogs = await this.prisma.strategyLog.findMany({ - where: { strategyId, action: 'signal_generated', signal: { not: null } }, - orderBy: { createdAt: 'asc' }, - }); - - return this.calculateFromSignals(signalLogs, strategy.config as Record); - } - - private async calculateFromOrders( - logs: Array<{ details: unknown; createdAt: Date }>, - ): Promise { - const orderIds = logs - .map((log) => { - const details = log.details as Record; - return details?.orderId as string | undefined; - }) - .filter((id): id is string => !!id); - - if (orderIds.length === 0) return this.emptyResult(); - - const orders = await this.prisma.order.findMany({ - where: { id: { in: orderIds }, status: 'filled' }, - orderBy: { createdAt: 'asc' }, - }); - - const buyOrders = orders.filter((o) => o.side === 'buy'); - const sellOrders = orders.filter((o) => o.side === 'sell'); - - let totalBuyCost = 0; - let totalBuyQty = 0; - for (const o of buyOrders) { - const qty = parseFloat(o.filledQuantity); - const price = parseFloat(o.filledPrice); - if (qty > 0 && price > 0) { - totalBuyCost += qty * price; - totalBuyQty += qty; - } - } - const avgBuyPrice = totalBuyQty > 0 ? totalBuyCost / totalBuyQty : 0; - - let realizedPnl = 0; - let wins = 0; - let losses = 0; - const dailyMap = new Map(); - - for (const o of sellOrders) { - const qty = parseFloat(o.filledQuantity); - const price = parseFloat(o.filledPrice); - const fee = parseFloat(o.fee); - if (qty <= 0 || price <= 0) continue; - - const tradePnl = (price - avgBuyPrice) * qty - fee; - realizedPnl += tradePnl; - if (tradePnl > 0) wins++; - else losses++; - - const date = o.createdAt.toISOString().split('T')[0]; - dailyMap.set(date, (dailyMap.get(date) || 0) + tradePnl); - } - - return this.buildResult( - orders.length, - buyOrders.length, - sellOrders.length, - wins, - losses, - realizedPnl, - dailyMap, - ); - } - - private calculateFromSignals( - logs: Array<{ signal: string | null; details: unknown; createdAt: Date }>, - config: Record, - ): PerformanceResult { - if (logs.length === 0) return this.emptyResult(); - - const quantity = Number(config.quantity) || 0.001; - let avgBuyPrice = 0; - let buyCount = 0; - let sellCount = 0; - let wins = 0; - let losses = 0; - let realizedPnl = 0; - let totalBuyCost = 0; - let totalBuyQty = 0; - const dailyMap = new Map(); - - for (const log of logs) { - const details = log.details as Record; - const price = Number(details?.price) || 0; - if (price <= 0) continue; - - if (log.signal === 'buy') { - buyCount++; - totalBuyCost += quantity * price; - totalBuyQty += quantity; - avgBuyPrice = totalBuyCost / totalBuyQty; - } else if (log.signal === 'sell' && avgBuyPrice > 0) { - sellCount++; - const tradePnl = (price - avgBuyPrice) * quantity; - realizedPnl += tradePnl; - if (tradePnl > 0) wins++; - else losses++; - - const date = log.createdAt.toISOString().split('T')[0]; - dailyMap.set(date, (dailyMap.get(date) || 0) + tradePnl); - } - } - - return this.buildResult( - buyCount + sellCount, - buyCount, - sellCount, - wins, - losses, - realizedPnl, - dailyMap, - ); - } - - private buildResult( - totalTrades: number, - buyTrades: number, - sellTrades: number, - wins: number, - losses: number, - realizedPnl: number, - dailyMap: Map, - ): PerformanceResult { - let cumulative = 0; - const dailyPnl = Array.from(dailyMap.entries()).map(([date, pnl]) => { - cumulative += pnl; - return { date, pnl: Math.round(cumulative * 100) / 100 }; - }); - - const totalSellTrades = wins + losses; - return { - totalTrades, - buyTrades, - sellTrades, - wins, - losses, - winRate: totalSellTrades > 0 ? Math.round((wins / totalSellTrades) * 100) : 0, - realizedPnl: Math.round(realizedPnl * 100) / 100, - dailyPnl, - }; - } - - private emptyResult(): PerformanceResult { - return { - totalTrades: 0, - buyTrades: 0, - sellTrades: 0, - wins: 0, - losses: 0, - winRate: 0, - realizedPnl: 0, - dailyPnl: [], - }; - } -} diff --git a/apps/api-server/src/strategies/queries/get-strategy-performance.query.ts b/apps/api-server/src/strategies/queries/get-strategy-performance.query.ts deleted file mode 100644 index 7ca55a2..0000000 --- a/apps/api-server/src/strategies/queries/get-strategy-performance.query.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class GetStrategyPerformanceQuery { - constructor( - public readonly userId: string, - public readonly strategyId: string, - ) {} -} diff --git a/apps/api-server/src/strategies/queries/get-strategy-signals.handler.test.ts b/apps/api-server/src/strategies/queries/get-strategy-signals.handler.test.ts deleted file mode 100644 index 2229a7b..0000000 --- a/apps/api-server/src/strategies/queries/get-strategy-signals.handler.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { NotFoundException } from '@nestjs/common'; -import { GetStrategySignalsHandler } from './get-strategy-signals.handler'; -import { GetStrategySignalsQuery } from './get-strategy-signals.query'; - -const mockPrisma = { - strategy: { findFirst: vi.fn() }, - strategyLog: { findMany: vi.fn() }, -}; - -describe('GetStrategySignalsHandler', () => { - let handler: GetStrategySignalsHandler; - - beforeEach(() => { - vi.clearAllMocks(); - handler = new GetStrategySignalsHandler(mockPrisma as never); - }); - - it('매핑된 시그널을 반환해야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue({ id: 'strat-1' }); - mockPrisma.strategyLog.findMany.mockResolvedValue([ - { - signal: 'buy', - action: 'signal_generated', - details: { price: 50000000 }, - createdAt: new Date('2025-01-01'), - }, - ]); - - const result = await handler.execute(new GetStrategySignalsQuery('user-1', 'strat-1')); - expect(result).toHaveLength(1); - expect(result[0].signal).toBe('buy'); - expect(result[0].price).toBe(50000000); - }); - - it('전략을 찾을 수 없으면 예외를 던져야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue(null); - - await expect( - handler.execute(new GetStrategySignalsQuery('user-1', 'non-existent')), - ).rejects.toThrow(NotFoundException); - }); -}); diff --git a/apps/api-server/src/strategies/queries/get-strategy-signals.handler.ts b/apps/api-server/src/strategies/queries/get-strategy-signals.handler.ts deleted file mode 100644 index 76f465a..0000000 --- a/apps/api-server/src/strategies/queries/get-strategy-signals.handler.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { PrismaService } from '../../prisma/prisma.service'; -import { GetStrategySignalsQuery } from './get-strategy-signals.query'; - -interface StrategySignal { - signal: string; - action: string; - price: number; - createdAt: string; -} - -@QueryHandler(GetStrategySignalsQuery) -export class GetStrategySignalsHandler implements IQueryHandler { - constructor(private readonly prisma: PrismaService) {} - - async execute(query: GetStrategySignalsQuery): Promise { - const { userId, strategyId } = query; - - const strategy = await this.prisma.strategy.findFirst({ - where: { id: strategyId, userId }, - }); - if (!strategy) throw new NotFoundException('Strategy not found'); - - const logs = await this.prisma.strategyLog.findMany({ - where: { - strategyId, - action: { in: ['signal_generated', 'order_placed'] }, - signal: { not: null }, - }, - orderBy: { createdAt: 'asc' }, - take: 500, - }); - - return logs.map((log) => { - const details = log.details as Record; - return { - signal: log.signal!, - action: log.action, - price: Number(details?.price) || 0, - createdAt: log.createdAt.toISOString(), - }; - }); - } -} diff --git a/apps/api-server/src/strategies/queries/get-strategy-signals.query.ts b/apps/api-server/src/strategies/queries/get-strategy-signals.query.ts deleted file mode 100644 index aa862b6..0000000 --- a/apps/api-server/src/strategies/queries/get-strategy-signals.query.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class GetStrategySignalsQuery { - constructor( - public readonly userId: string, - public readonly strategyId: string, - ) {} -} diff --git a/apps/api-server/src/strategies/queries/get-strategy.handler.test.ts b/apps/api-server/src/strategies/queries/get-strategy.handler.test.ts deleted file mode 100644 index f5fee47..0000000 --- a/apps/api-server/src/strategies/queries/get-strategy.handler.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { NotFoundException } from '@nestjs/common'; -import { GetStrategyHandler } from './get-strategy.handler'; -import { GetStrategyQuery } from './get-strategy.query'; - -const mockPrisma = { strategy: { findFirst: vi.fn() } }; - -describe('GetStrategyHandler', () => { - let handler: GetStrategyHandler; - - beforeEach(() => { - vi.clearAllMocks(); - handler = new GetStrategyHandler(mockPrisma as never); - }); - - it('전략을 반환해야 한다', async () => { - const strategy = { id: 'strat-1', name: 'RSI', userId: 'user-1' }; - mockPrisma.strategy.findFirst.mockResolvedValue(strategy); - - const result = await handler.execute(new GetStrategyQuery('user-1', 'strat-1')); - expect(result).toEqual(strategy); - }); - - it('찾을 수 없으면 예외를 던져야 한다', async () => { - mockPrisma.strategy.findFirst.mockResolvedValue(null); - - await expect(handler.execute(new GetStrategyQuery('user-1', 'non-existent'))).rejects.toThrow( - NotFoundException, - ); - }); -}); diff --git a/apps/api-server/src/strategies/queries/get-strategy.handler.ts b/apps/api-server/src/strategies/queries/get-strategy.handler.ts deleted file mode 100644 index fc063d9..0000000 --- a/apps/api-server/src/strategies/queries/get-strategy.handler.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { PrismaService } from '../../prisma/prisma.service'; -import { GetStrategyQuery } from './get-strategy.query'; - -@QueryHandler(GetStrategyQuery) -export class GetStrategyHandler implements IQueryHandler { - constructor(private readonly prisma: PrismaService) {} - - async execute(query: GetStrategyQuery): Promise { - const strategy = await this.prisma.strategy.findFirst({ - where: { id: query.id, userId: query.userId }, - }); - if (!strategy) throw new NotFoundException('Strategy not found'); - return strategy; - } -} diff --git a/apps/api-server/src/strategies/queries/get-strategy.query.ts b/apps/api-server/src/strategies/queries/get-strategy.query.ts deleted file mode 100644 index ba11ffc..0000000 --- a/apps/api-server/src/strategies/queries/get-strategy.query.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class GetStrategyQuery { - constructor( - public readonly userId: string, - public readonly id: string, - ) {} -} diff --git a/apps/api-server/src/strategies/queries/index.ts b/apps/api-server/src/strategies/queries/index.ts deleted file mode 100644 index 7c84d39..0000000 --- a/apps/api-server/src/strategies/queries/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { GetStrategiesHandler } from './get-strategies.handler'; -import { GetStrategyHandler } from './get-strategy.handler'; -import { GetStrategyLogsHandler } from './get-strategy-logs.handler'; -import { GetStrategyPerformanceHandler } from './get-strategy-performance.handler'; -import { GetStrategySignalsHandler } from './get-strategy-signals.handler'; - -export const StrategyQueryHandlers = [ - GetStrategiesHandler, - GetStrategyHandler, - GetStrategyLogsHandler, - GetStrategyPerformanceHandler, - GetStrategySignalsHandler, -]; - -export { GetStrategiesQuery } from './get-strategies.query'; -export { GetStrategyQuery } from './get-strategy.query'; -export { GetStrategyLogsQuery } from './get-strategy-logs.query'; -export { GetStrategyPerformanceQuery } from './get-strategy-performance.query'; -export { GetStrategySignalsQuery } from './get-strategy-signals.query'; diff --git a/apps/api-server/src/strategies/strategies.controller.ts b/apps/api-server/src/strategies/strategies.controller.ts deleted file mode 100644 index 0533521..0000000 --- a/apps/api-server/src/strategies/strategies.controller.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { - Controller, - Get, - Post, - Patch, - Delete, - Body, - Param, - Query, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import { - ApiTags, - ApiBearerAuth, - ApiOperation, - ApiResponse, - ApiParam, - ApiQuery, -} from '@nestjs/swagger'; -import { CommandBus, QueryBus } from '@nestjs/cqrs'; -import { CurrentUser } from '../auth/decorators/current-user.decorator'; -import { - CreateStrategyCommand, - UpdateStrategyCommand, - ToggleStrategyCommand, - DeleteStrategyCommand, - ReorderStrategiesCommand, -} from './commands'; -import { - GetStrategiesQuery, - GetStrategyQuery, - GetStrategyLogsQuery, - GetStrategyPerformanceQuery, - GetStrategySignalsQuery, -} from './queries'; -import { CreateStrategyDto } from './dto/create-strategy.dto'; -import { UpdateStrategyDto } from './dto/update-strategy.dto'; -import { ReorderStrategiesDto } from './dto/reorder-strategies.dto'; -import { - StrategyResponse, - StrategyPerformanceResponse, - StrategySignalResponse, - StrategyLogListResponse, -} from './dto/strategy-response.dto'; -import type { User } from '@coin/database'; - -@ApiTags('Strategies') -@ApiBearerAuth('access-token') -@Controller('strategies') -export class StrategiesController { - constructor( - private readonly commandBus: CommandBus, - private readonly queryBus: QueryBus, - ) {} - - @Post() - @ApiOperation({ - summary: '새 트레이딩 전략 생성', - description: - '## 전략 실행 사이클\n\n' + - '```mermaid\n' + - 'sequenceDiagram\n' + - ' participant W as Worker\n' + - ' participant R as Redis\n' + - ' participant E as 거래소 API\n' + - ' participant K as Kafka\n' + - ' loop 매 intervalSeconds 마다\n' + - ' W->>E: getCandles(symbol, candleInterval)\n' + - ' E-->>W: OHLCV 데이터\n' + - ' W->>R: 캔들 캐시\n' + - ' W->>W: 지표 계산 (RSI/MACD/Bollinger)\n' + - ' alt Signal 모드\n' + - ' W->>K: StrategySignalEvent 발행\n' + - ' K->>W: 알림 전송\n' + - ' else Auto 모드\n' + - ' W->>W: 리스크 체크\n' + - ' W->>K: OrderRequestedEvent 발행\n' + - ' K->>W: 주문 실행\n' + - ' end\n' + - ' end\n' + - '```\n' + - '\n\n새 트레이딩 전략을 생성합니다. RSI, MACD, Bollinger Bands 유형을 지원하며, 각 전략은 설정된 캔들 간격과 실행 주기에 따라 자동으로 시그널을 생성합니다.', - }) - @ApiResponse({ status: 201, description: '전략 생성 성공', type: StrategyResponse }) - @ApiResponse({ status: 401, description: '인증 필요' }) - async create(@CurrentUser() user: User, @Body() dto: CreateStrategyDto) { - return this.commandBus.execute(new CreateStrategyCommand(user.id, dto)); - } - - @Get() - @ApiOperation({ - summary: '현재 사용자의 모든 전략 조회', - description: - '현재 사용자의 모든 전략 목록을 반환합니다. 활성/비활성 상태, 전략 유형, 설정 등을 포함합니다.', - }) - @ApiResponse({ status: 200, description: '전략 목록 반환', type: [StrategyResponse] }) - @ApiResponse({ status: 401, description: '인증 필요' }) - async findAll(@CurrentUser() user: User) { - return this.queryBus.execute(new GetStrategiesQuery(user.id)); - } - - @Get(':id') - @ApiOperation({ - summary: 'ID로 특정 전략 조회', - description: '전략 ID로 특정 전략의 상세 설정을 조회합니다.', - }) - @ApiResponse({ status: 200, description: '전략 상세 반환', type: StrategyResponse }) - @ApiResponse({ status: 401, description: '인증 필요' }) - @ApiParam({ name: 'id', description: '전략 ID' }) - async findOne(@CurrentUser() user: User, @Param('id') id: string) { - return this.queryBus.execute(new GetStrategyQuery(user.id, id)); - } - - @Patch(':id') - @ApiOperation({ - summary: '기존 전략 설정 수정', - description: - '전략의 이름, 모드, 파라미터, 리스크 설정, 캔들 간격 등을 수정합니다. 전략 유형과 거래소/심볼은 변경할 수 없습니다.', - }) - @ApiResponse({ status: 200, description: '전략 수정 성공', type: StrategyResponse }) - @ApiResponse({ status: 401, description: '인증 필요' }) - @ApiParam({ name: 'id', description: '전략 ID' }) - async update(@CurrentUser() user: User, @Param('id') id: string, @Body() dto: UpdateStrategyDto) { - return this.commandBus.execute(new UpdateStrategyCommand(user.id, id, dto)); - } - - @Patch(':id/toggle') - @ApiOperation({ - summary: '전략 활성/비활성 전환', - description: - '전략의 활성/비활성 상태를 전환합니다. 활성화하면 Worker가 설정된 간격으로 시그널을 생성합니다.', - }) - @ApiResponse({ status: 200, description: '전략 전환 성공' }) - @ApiResponse({ status: 401, description: '인증 필요' }) - @ApiParam({ name: 'id', description: '전략 ID' }) - async toggle(@CurrentUser() user: User, @Param('id') id: string) { - return this.commandBus.execute(new ToggleStrategyCommand(user.id, id)); - } - - @Patch('reorder') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ - summary: '전략 카드 순서 일괄 업데이트', - description: - '전략 목록의 표시 순서를 업데이트합니다. DnD 후 변경된 순서를 저장할 때 사용합니다.', - }) - @ApiResponse({ status: 204, description: '순서 업데이트 성공' }) - @ApiResponse({ status: 400, description: '잘못된 전략 ID 또는 권한 없음' }) - @ApiResponse({ status: 401, description: '인증 필요' }) - async reorder(@CurrentUser() user: User, @Body() dto: ReorderStrategiesDto) { - return this.commandBus.execute(new ReorderStrategiesCommand(user.id, dto)); - } - - @Delete(':id') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: '전략 영구 삭제', - description: '전략과 관련된 모든 실행 로그를 함께 영구 삭제합니다.', - }) - @ApiResponse({ status: 200, description: '전략 삭제 성공' }) - @ApiResponse({ status: 401, description: '인증 필요' }) - @ApiParam({ name: 'id', description: '전략 ID' }) - async remove(@CurrentUser() user: User, @Param('id') id: string) { - return this.commandBus.execute(new DeleteStrategyCommand(user.id, id)); - } - - @Get(':id/performance') - @ApiOperation({ - summary: '전략 성과 지표 조회', - description: - '전략의 성과 지표를 조회합니다. 총 거래 수, 승률, 실현 손익, 일별 누적 P&L을 반환합니다. Signal 모드에서는 시뮬레이션 기반 성과를 계산합니다.', - }) - @ApiResponse({ - status: 200, - description: '전략 성과 데이터 반환', - type: StrategyPerformanceResponse, - }) - @ApiResponse({ status: 401, description: '인증 필요' }) - @ApiParam({ name: 'id', description: '전략 ID' }) - async getPerformance(@CurrentUser() user: User, @Param('id') id: string) { - return this.queryBus.execute(new GetStrategyPerformanceQuery(user.id, id)); - } - - @Get(':id/signals') - @ApiOperation({ - summary: '전략이 생성한 트레이딩 시그널 목록', - description: '전략이 생성한 매수/매도 시그널 목록을 반환합니다. 차트의 마커 표시에 사용됩니다.', - }) - @ApiResponse({ status: 200, description: '전략 시그널 반환', type: [StrategySignalResponse] }) - @ApiResponse({ status: 401, description: '인증 필요' }) - @ApiParam({ name: 'id', description: '전략 ID' }) - async getSignals(@CurrentUser() user: User, @Param('id') id: string) { - return this.queryBus.execute(new GetStrategySignalsQuery(user.id, id)); - } - - @Get(':id/logs') - @ApiOperation({ - summary: '전략 실행 로그 조회 (필터 지원)', - description: - '전략의 실행 로그를 조회합니다. 액션(signal_generated/order_placed/risk_blocked/error)과 시그널(buy/sell)로 필터링할 수 있습니다.', - }) - @ApiResponse({ status: 200, description: '전략 로그 반환', type: StrategyLogListResponse }) - @ApiResponse({ status: 401, description: '인증 필요' }) - @ApiParam({ name: 'id', description: '전략 ID' }) - @ApiQuery({ name: 'cursor', required: false, description: '페이지네이션 커서' }) - @ApiQuery({ name: 'limit', required: false, description: '페이지당 항목 수' }) - @ApiQuery({ name: 'action', required: false, description: '로그 액션 유형 필터' }) - @ApiQuery({ name: 'signal', required: false, description: '시그널 유형 필터' }) - async getLogs( - @CurrentUser() user: User, - @Param('id') id: string, - @Query('cursor') cursor?: string, - @Query('limit') limit?: string, - @Query('action') action?: string, - @Query('signal') signal?: string, - ) { - return this.queryBus.execute( - new GetStrategyLogsQuery( - user.id, - id, - cursor, - limit ? parseInt(limit, 10) : undefined, - action, - signal, - ), - ); - } -} diff --git a/apps/api-server/src/strategies/strategies.module.ts b/apps/api-server/src/strategies/strategies.module.ts deleted file mode 100644 index b6ba2da..0000000 --- a/apps/api-server/src/strategies/strategies.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CqrsModule } from '@nestjs/cqrs'; -import { StrategiesController } from './strategies.controller'; -import { StrategyCommandHandlers } from './commands'; -import { StrategyQueryHandlers } from './queries'; - -@Module({ - imports: [CqrsModule], - controllers: [StrategiesController], - providers: [...StrategyCommandHandlers, ...StrategyQueryHandlers], -}) -export class StrategiesModule {} diff --git a/apps/web/docker-compose.dev.yml b/apps/web/docker-compose.dev.yml index d4ba437..01a04e7 100644 --- a/apps/web/docker-compose.dev.yml +++ b/apps/web/docker-compose.dev.yml @@ -9,7 +9,6 @@ services: - JWT_ACCESS_EXPIRES_IN=${JWT_ACCESS_EXPIRES_IN:-15m} volumes: - ../../:/app - - /app/node_modules expose: - '3001' networks: [coin-net] diff --git a/apps/web/src/app/activity/page.tsx b/apps/web/src/app/activity/page.tsx index 0c90b51..6eeb6f5 100644 --- a/apps/web/src/app/activity/page.tsx +++ b/apps/web/src/app/activity/page.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { useInfiniteQuery } from '@tanstack/react-query'; -import { ShoppingCart, BrainCircuit, Shield, LogIn, LogOut } from 'lucide-react'; +import { ShoppingCart, LogIn, LogOut } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { ExchangeIcon, CoinIcon } from '@/components/icons'; @@ -40,35 +40,6 @@ function ActivityIcon({ type, side }: { type: ActivityItem['type']; side?: strin /> ); - case 'strategy_signal': - return ( -
- -
- ); - case 'strategy_order': - return ( -
- -
- ); - case 'risk_blocked': - return ( -
- -
- ); case 'login': return side === 'logout' ? (
@@ -140,7 +111,7 @@ export default function ActivityPage() { const items = data?.pages.flatMap((p) => p.items) ?? []; const filteredItems = typeFilter === 'all' ? items : items.filter((i) => i.type === typeFilter); - const typeOptions = ['all', 'order', 'strategy_signal', 'risk_blocked', 'login'] as const; + const typeOptions = ['all', 'order', 'login'] as const; return (
diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 38bf7a9..7c91d4b 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -1,285 +1,20 @@ 'use client'; -import { useState, useCallback } from 'react'; -import Link from 'next/link'; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from '@dnd-kit/core'; -import { - SortableContext, - sortableKeyboardCoordinates, - rectSortingStrategy, - arrayMove, - useSortable, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { GripVertical, BrainCircuit, PieChart, ShoppingCart, BarChart3 } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; -import { useStrategies } from '@/hooks/use-strategies'; -import { usePortfolio } from '@/hooks/use-portfolio'; -import { useOrders } from '@/hooks/use-orders'; -import { useTickers } from '@/hooks/use-tickers'; -import { formatKrw } from '@/lib/utils'; - -const DEFAULT_WIDGET_ORDER = ['portfolio', 'strategies', 'orders', 'markets'] as const; -type WidgetId = (typeof DEFAULT_WIDGET_ORDER)[number]; - -const STORAGE_KEY = 'dashboard-widget-order'; - -function loadWidgetOrder(): WidgetId[] { - if (typeof window === 'undefined') return [...DEFAULT_WIDGET_ORDER]; - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored) as string[]; - const valid = parsed.filter((id): id is WidgetId => - (DEFAULT_WIDGET_ORDER as readonly string[]).includes(id), - ); - if (valid.length === DEFAULT_WIDGET_ORDER.length) return valid; - } - } catch { - // ignore - } - return [...DEFAULT_WIDGET_ORDER]; -} - -function saveWidgetOrder(order: WidgetId[]) { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(order)); - } catch { - // ignore - } -} - -function PortfolioWidget() { - const { data } = usePortfolio('all'); - const totalPnl = data ? data.realizedPnl + data.unrealizedPnl : null; - return ( - - - - - - 포트폴리오 - - - 자세히 → - - - - - {data ? ( -
-
-

총 평가금액

-

{formatKrw(data.totalValueKrw)}

-
-
-

총 손익

-

= 0 ? 'text-blue-600' : 'text-red-600'}`} - > - {totalPnl !== null ? formatKrw(totalPnl) : '-'} -

-
-
- ) : ( -

데이터 없음

- )} -
-
- ); -} - -function StrategiesWidget() { - const { data: strategies = [] } = useStrategies(); - const active = strategies.filter((s) => s.enabled); - return ( - - - - - - 전략 - - - 자세히 → - - - - -
-
-
-

전체

-

{strategies.length}

-
-
-

실행 중

-

{active.length}

-
-
- {active.slice(0, 3).map((s) => ( -
- • {s.name} ({s.symbol}) -
- ))} -
-
-
- ); -} - -function OrdersWidget() { - const { data } = useOrders(); - const orders = data?.pages[0]?.items?.slice(0, 5) ?? []; - return ( - - - - - - 최근 주문 - - - 자세히 → - - - - - {orders.length === 0 ? ( -

주문 없음

- ) : ( -
- {orders.map((o) => ( -
- - {o.side === 'buy' ? '매수' : '매도'} {o.symbol} - - {o.status} -
- ))} -
- )} -
-
- ); -} - -function MarketsWidget() { - const { tickers } = useTickers(); - const top = tickers.slice(0, 5); - return ( - - - - - - 시장 - - - 자세히 → - - - - - {top.length === 0 ? ( -

데이터 없음

- ) : ( -
- {top.map((t) => { - const pct = parseFloat(t.changePercent24h); - return ( -
- {t.symbol} - = 0 ? 'text-blue-600' : 'text-red-600'}> - {pct >= 0 ? '+' : ''} - {pct.toFixed(2)}% - -
- ); - })} -
- )} -
-
- ); -} - -const WIDGET_COMPONENTS: Record = { - portfolio: PortfolioWidget, - strategies: StrategiesWidget, - orders: OrdersWidget, - markets: MarketsWidget, -}; - -function SortableWidget({ id }: { id: WidgetId }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id, - }); - const WidgetComponent = WIDGET_COMPONENTS[id]; - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( -
- - -
- ); -} export default function DashboardPage() { - const [widgetOrder, setWidgetOrder] = useState(() => loadWidgetOrder()); - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) return; - - const oldIndex = widgetOrder.indexOf(active.id as WidgetId); - const newIndex = widgetOrder.indexOf(over.id as WidgetId); - const newOrder = arrayMove(widgetOrder, oldIndex, newIndex); - setWidgetOrder(newOrder); - saveWidgetOrder(newOrder); - }, - [widgetOrder], - ); - return ( -
-

대시보드

- - -
- {widgetOrder.map((id) => ( - - ))} -
-
-
+
+ + + Dashboard + + +

+ LLM-driven Binance Futures trading is being rebuilt. Check back soon. +

+
+
); } diff --git a/apps/web/src/app/flows/[id]/page.tsx b/apps/web/src/app/flows/[id]/page.tsx deleted file mode 100644 index 5a9334a..0000000 --- a/apps/web/src/app/flows/[id]/page.tsx +++ /dev/null @@ -1,73 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { useParams } from 'next/navigation'; -import dynamic from 'next/dynamic'; -import { useTranslations } from 'next-intl'; -import { useFlow } from '@/hooks/use-flows'; -import { useUser } from '@/hooks/use-user'; -import { useBacktestWs } from '@/hooks/use-backtest-ws'; -import { useFlowStore } from '@/stores/use-flow-store'; - -const FlowCanvas = dynamic( - () => import('@/components/flows/flow-canvas').then((m) => ({ default: m.FlowCanvas })), - { ssr: false }, -); - -import { NodePalette } from '@/components/flows/node-palette'; -import { NodeInspector } from '@/components/flows/node-inspector'; -import { FlowToolbar } from '@/components/flows/flow-toolbar'; -import { TimelineSlider } from '@/components/flows/timeline-slider'; - -export default function FlowBuilderPage() { - const t = useTranslations('flows'); - const tc = useTranslations('common'); - const { id } = useParams<{ id: string }>(); - const { user } = useUser(); - const { data: flow, isLoading } = useFlow(id); - const loadFlow = useFlowStore((s) => s.loadFlow); - const flowId = useFlowStore((s) => s.flowId); - - // Listen for backtest completion via WebSocket - useBacktestWs(user?.id ?? null, id); - - // Load flow data into store when fetched - useEffect(() => { - if (flow && flow.id !== flowId) { - loadFlow(flow.id, flow.name, flow.definition); - } - }, [flow, flowId, loadFlow]); - - if (isLoading) { - return ( -
-
{tc('loading')}
-
- ); - } - - return ( -
- {/* Mobile read-only banner */} -
- {t('mobileReadOnly')} -
- - - -
-
- -
- - - -
- -
-
- - -
- ); -} diff --git a/apps/web/src/app/flows/page.tsx b/apps/web/src/app/flows/page.tsx deleted file mode 100644 index fcc397e..0000000 --- a/apps/web/src/app/flows/page.tsx +++ /dev/null @@ -1,121 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useRouter } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import { Plus, Workflow } from 'lucide-react'; -import { createFlow, deleteFlow } from '@/lib/api-client'; -import { useFlows } from '@/hooks/use-flows'; -import { useToastStore } from '@/stores/use-toast-store'; -import { FlowCard } from '@/components/flows/flow-card'; -import { SkeletonCard } from '@/components/ui/skeleton'; -import { TemplatePickerModal } from '@/components/flows/template-picker-modal'; -import type { FlowTemplate } from '@/lib/flow-templates'; - -export default function FlowsPage() { - const t = useTranslations('flows'); - const router = useRouter(); - const queryClient = useQueryClient(); - const addToast = useToastStore((s) => s.addToast); - const { data: flows = [], isLoading } = useFlows(); - const [showTemplatePicker, setShowTemplatePicker] = useState(false); - - const createMutation = useMutation({ - mutationFn: (template: FlowTemplate | null) => - createFlow({ - name: template ? template.name : '새 플로우', - definition: template ? template.definition : { nodes: [], edges: [] }, - exchange: 'binance', - symbol: template ? (template.recommendedPairs[0] ?? 'BTC/USDT') : 'BTC/USDT', - }), - onSuccess: (flow) => { - queryClient.invalidateQueries({ queryKey: ['flows'] }); - router.push(`/flows/${flow.id}`); - }, - onError: (err: Error) => { - addToast({ type: 'error', title: '생성 실패', message: err.message }); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: deleteFlow, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['flows'] }); - addToast({ type: 'success', title: '삭제됨', message: '플로우가 삭제되었습니다.' }); - }, - }); - - function handleOpenPicker() { - setShowTemplatePicker(true); - } - - function handleTemplateSelect(template: FlowTemplate | null) { - setShowTemplatePicker(false); - createMutation.mutate(template); - } - - return ( -
-
-

{t('title')}

- -
- - {isLoading && ( -
- - - -
- )} - - {!isLoading && flows.length === 0 && ( -
- -
-

{t('noFlows')}

-

{t('emptyDesc')}

-
- -
- )} - - {flows.length > 0 && ( -
- {flows.map((flow) => ( - { - if (confirm(t('deleteConfirm'))) { - deleteMutation.mutate(flow.id); - } - }} - /> - ))} -
- )} - - {showTemplatePicker && ( - setShowTemplatePicker(false)} - /> - )} -
- ); -} diff --git a/apps/web/src/app/markets/[exchange]/[symbol]/page.tsx b/apps/web/src/app/markets/[exchange]/[symbol]/page.tsx index 6323554..f9ffb46 100644 --- a/apps/web/src/app/markets/[exchange]/[symbol]/page.tsx +++ b/apps/web/src/app/markets/[exchange]/[symbol]/page.tsx @@ -50,9 +50,10 @@ export default function MarketDetailPage() { {krwPerUsd > 0 && ( - {exchange === 'upbit' - ? `$${(Number(ticker.price) / krwPerUsd).toLocaleString('en-US', { maximumFractionDigits: 2 })}` - : `₩${(Number(ticker.price) * krwPerUsd).toLocaleString('ko-KR', { maximumFractionDigits: 0 })}`} + ₩ + {(Number(ticker.price) * krwPerUsd).toLocaleString('ko-KR', { + maximumFractionDigits: 0, + })} )}
diff --git a/apps/web/src/app/markets/page.tsx b/apps/web/src/app/markets/page.tsx index e0cae11..79056cb 100644 --- a/apps/web/src/app/markets/page.tsx +++ b/apps/web/src/app/markets/page.tsx @@ -1,18 +1,14 @@ 'use client'; -import { useState } from 'react'; import { useTranslations } from 'next-intl'; import { useTickers } from '@/hooks/use-tickers'; import { TickerTable } from '@/components/ticker-table'; import { TickerCardList } from '@/components/markets/ticker-card-list'; import { ExchangeRateBadge } from '@/components/exchange-rate-badge'; -import { QuickOrderPanel } from '@/components/orders/quick-order-panel'; -import type { Ticker } from '@coin/types'; export default function MarketsPage() { const { tickers, connected } = useTickers(); const t = useTranslations('markets'); - const [selectedTicker, setSelectedTicker] = useState(null); return (
@@ -29,15 +25,12 @@ export default function MarketsPage() {
- {/* Mobile: card view (QuickOrderPanel handled internally via swipe) */}
- {/* Desktop: table view */}
- - setSelectedTicker(null)} /> +
); diff --git a/apps/web/src/app/notifications/page.tsx b/apps/web/src/app/notifications/page.tsx index c020a78..3e21429 100644 --- a/apps/web/src/app/notifications/page.tsx +++ b/apps/web/src/app/notifications/page.tsx @@ -115,16 +115,6 @@ export default function NotificationsPage() { checked={settings?.notifyOrders ?? true} onChange={(v) => mutation.mutate({ notifyOrders: v })} /> - mutation.mutate({ notifySignals: v })} - /> - mutation.mutate({ notifyRisks: v })} - /> {saved &&

{t('saved')}

} diff --git a/apps/web/src/app/orders/page.tsx b/apps/web/src/app/orders/page.tsx deleted file mode 100644 index f0347b1..0000000 --- a/apps/web/src/app/orders/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Card, CardContent } from '@/components/ui/card'; -import { getExchangeKeys } from '@/lib/api-client'; -import { useTickers } from '@/hooks/use-tickers'; -import { useOrderUpdates } from '@/hooks/use-order-updates'; -import { useUser } from '@/hooks/use-user'; -import { useTranslations } from 'next-intl'; -import { CandleChart } from '@/components/candle-chart'; -import { OrderForm } from '@/components/orders/order-form'; -import { OrdersTable } from '@/components/orders/orders-table'; - -export default function OrdersPage() { - const t = useTranslations('orders'); - const queryClient = useQueryClient(); - const { user } = useUser(); - const [selectedExchange, setSelectedExchange] = useState(''); - const [selectedSymbol, setSelectedSymbol] = useState(''); - - const { data: keys = [] } = useQuery({ - queryKey: ['exchangeKeys'], - queryFn: getExchangeKeys, - }); - - const { tickers } = useTickers(); - - useOrderUpdates(user?.id ?? null); - - const handleSelectionChange = (exchange: string, symbol: string) => { - setSelectedExchange(exchange); - setSelectedSymbol(symbol); - }; - - return ( -
-

{t('title')}

- - {selectedExchange && selectedSymbol && ( - - - - - - )} - -
-
- queryClient.invalidateQueries({ queryKey: ['orders'] })} - onSelectionChange={handleSelectionChange} - /> -
-
- -
-
-
- ); -} diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 9a314ad..f4cde64 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -113,7 +113,7 @@ function GeneralTab() { function AccountsTab() { const queryClient = useQueryClient(); - const [exchange, setExchange] = useState('upbit'); + const [exchange, setExchange] = useState('binance'); const [apiKey, setApiKey] = useState(''); const [secretKey, setSecretKey] = useState(''); const [error, setError] = useState(''); @@ -350,16 +350,6 @@ function NotificationsTab() { checked={settings?.notifyOrders ?? true} onChange={(v) => mutation.mutate({ notifyOrders: v })} /> - mutation.mutate({ notifySignals: v })} - /> - mutation.mutate({ notifyRisks: v })} - /> {saved &&

설정이 저장되었습니다

} diff --git a/apps/web/src/app/strategies/[id]/page.tsx b/apps/web/src/app/strategies/[id]/page.tsx deleted file mode 100644 index 39f5082..0000000 --- a/apps/web/src/app/strategies/[id]/page.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'use client'; - -import { use } from 'react'; -import Link from 'next/link'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useRouter } from 'next/navigation'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; -import { ExchangeIcon, CoinIcon } from '@/components/icons'; -import { StrategyChart } from '@/components/strategy-chart'; -import { getStrategy, toggleStrategy, deleteStrategy } from '@/lib/api-client'; -import { SkeletonCard, SkeletonChart, SkeletonTable } from '@/components/ui/skeleton'; -import { StrategyInfo } from '@/components/strategies/strategy-info'; -import { PerformanceCard } from '@/components/strategies/performance-card'; -import { ExecutionLogs } from '@/components/strategies/execution-logs'; - -export default function StrategyDetailPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params); - const router = useRouter(); - const queryClient = useQueryClient(); - - const { data: strategy, isLoading } = useQuery({ - queryKey: ['strategy', id], - queryFn: () => getStrategy(id), - }); - - const toggleMutation = useMutation({ - mutationFn: () => toggleStrategy(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['strategy', id] }); - queryClient.invalidateQueries({ queryKey: ['strategies'] }); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: () => deleteStrategy(id), - onSuccess: () => router.push('/strategies'), - }); - - if (isLoading) { - return ( -
- - - - - - - - - - - - -
- ); - } - - if (!strategy) { - return ( -
-

Strategy not found

-
- ); - } - - const config = strategy.config as Record; - - return ( -
-
- - Strategies - - / - {strategy.name} -
- - toggleMutation.mutate()} - onDelete={() => deleteMutation.mutate()} - /> - - - - {/* Strategy Chart with Indicator */} - - - - - - {strategy.exchange.toUpperCase()} — {strategy.symbol} - - - - - - - - -
- ); -} diff --git a/apps/web/src/app/strategies/page.tsx b/apps/web/src/app/strategies/page.tsx deleted file mode 100644 index c02c025..0000000 --- a/apps/web/src/app/strategies/page.tsx +++ /dev/null @@ -1,183 +0,0 @@ -'use client'; - -import { useState, useMemo } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useTranslations } from 'next-intl'; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from '@dnd-kit/core'; -import { - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, - arrayMove, - useSortable, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { GripVertical } from 'lucide-react'; -import { - toggleStrategy, - deleteStrategy, - reorderStrategies, - type StrategyItem, -} from '@/lib/api-client'; -import { useStrategies } from '@/hooks/use-strategies'; -import { useExchangeKeys } from '@/hooks/use-exchange-keys'; -import { useUIMode } from '@/hooks/use-ui-mode'; -import { StrategyCard } from '@/components/strategies/strategy-card'; -import { CreateStrategyForm } from '@/components/strategies/create-strategy-form'; -import { EasyStrategyWizard } from '@/components/strategies/easy-strategy-wizard'; -import { SkeletonCard } from '@/components/ui/skeleton'; - -function SortableStrategyCard({ - strategy, - onToggle, - onDelete, -}: { - strategy: StrategyItem; - onToggle: () => void; - onDelete: () => void; -}) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: strategy.id, - }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( -
- -
- -
-
- ); -} - -export default function StrategiesPage() { - const t = useTranslations('strategies'); - const queryClient = useQueryClient(); - const { isEasy } = useUIMode(); - const { data: strategies = [], isLoading } = useStrategies(); - const { data: keys = [] } = useExchangeKeys(); - const [localOrder, setLocalOrder] = useState(null); - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), - ); - - const toggleMutation = useMutation({ - mutationFn: toggleStrategy, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['strategies'] }), - }); - - const deleteMutation = useMutation({ - mutationFn: deleteStrategy, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['strategies'] }), - }); - - const reorderMutation = useMutation({ - mutationFn: reorderStrategies, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['strategies'] }), - }); - - const sortedStrategies = useMemo(() => { - if (!localOrder) return [...strategies].sort((a, b) => a.order - b.order); - return localOrder - .map((id) => strategies.find((s) => s.id === id)) - .filter(Boolean) as StrategyItem[]; - }, [strategies, localOrder]); - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) return; - - const oldIndex = sortedStrategies.findIndex((s) => s.id === active.id); - const newIndex = sortedStrategies.findIndex((s) => s.id === over.id); - const newOrder = arrayMove(sortedStrategies, oldIndex, newIndex); - - setLocalOrder(newOrder.map((s) => s.id)); - reorderMutation.mutate(newOrder.map((s, i) => ({ id: s.id, order: i }))); - }; - - return ( -
-

{t('title')}

- -
-
- {isEasy ? ( - { - setLocalOrder(null); - queryClient.invalidateQueries({ queryKey: ['strategies'] }); - }} - /> - ) : ( - { - setLocalOrder(null); - queryClient.invalidateQueries({ queryKey: ['strategies'] }); - }} - /> - )} -
-
- {isLoading && ( - <> - - - - )} - {!isLoading && sortedStrategies.length === 0 && ( -

{t('noStrategies')}

- )} - {!isLoading && sortedStrategies.length > 0 && ( - - s.id)} - strategy={verticalListSortingStrategy} - > - {sortedStrategies.map((s) => ( - toggleMutation.mutate(s.id)} - onDelete={() => { - deleteMutation.mutate(s.id); - setLocalOrder(null); - }} - /> - ))} - - - )} -
-
-
- ); -} diff --git a/apps/web/src/components/candle-chart.tsx b/apps/web/src/components/candle-chart.tsx index 5c94127..1c14909 100644 --- a/apps/web/src/components/candle-chart.tsx +++ b/apps/web/src/components/candle-chart.tsx @@ -2,24 +2,9 @@ import { useEffect, useRef, useState } from 'react'; import { createChart, ColorType, type IChartApi, type ISeriesApi } from 'lightweight-charts'; -import { GitCompare } from 'lucide-react'; import { useCandles } from '@/hooks/use-candles'; -import { useTickersStore } from '@/stores/use-tickers-store'; -import { useCompareChart, parseCoinFromSymbol } from '@/hooks/use-compare-chart'; -import { useBaseCurrency } from '@/hooks/use-base-currency'; -import { useExchangeRate } from '@/hooks/use-exchange-rate'; const INTERVALS = ['1m', '5m', '15m', '1h', '4h', '1d'] as const; -const PRICE_TYPES = ['close', 'high', 'low', 'mid'] as const; -type PriceType = (typeof PRICE_TYPES)[number]; - -const EXCHANGE_COLORS: Record = { - upbit: '#3b82f6', - binance: '#f59e0b', - bybit: '#f97316', -}; - -const ALL_EXCHANGES = ['upbit', 'binance', 'bybit']; interface CandleChartProps { exchange: string; @@ -29,24 +14,6 @@ interface CandleChartProps { export function CandleChart({ exchange, symbol, height = 400 }: CandleChartProps) { const [selectedInterval, setSelectedInterval] = useState('1h'); - const [compareMode, setCompareMode] = useState(false); - const [compareExchange, setCompareExchange] = useState(''); - const [priceType, setPriceType] = useState('close'); - const { currency: baseCurrency } = useBaseCurrency(); - const baseCoin = parseCoinFromSymbol(symbol); - - const otherExchanges = ALL_EXCHANGES.filter((ex) => ex !== exchange); - if (compareMode && !compareExchange && otherExchanges.length > 0) { - setCompareExchange(otherExchanges[0]); - } - - const { lines: compareLines } = useCompareChart( - baseCoin, - selectedInterval, - priceType, - exchange, - compareMode, - ); const chartRef = useRef(null); const chartInstance = useRef(null); const candleSeriesRef = useRef | null>(null); @@ -54,28 +21,6 @@ export function CandleChart({ exchange, symbol, height = 400 }: CandleChartProps const { data: candles, isLoading } = useCandles(exchange, symbol, selectedInterval); - const compareSeriesRef = useRef | null>(null); - - const tickerKey = `${exchange}:${symbol}`; - const ticker = useTickersStore((s) => s.tickers.get(tickerKey)); - const { krwPerUsd } = useExchangeRate(); - - // Compare exchange ticker for real-time price - const COIN_SYMBOL_MAP: Record> = { - BTC: { upbit: 'KRW-BTC', binance: 'BTCUSDT', bybit: 'BTCUSDT' }, - ETH: { upbit: 'KRW-ETH', binance: 'ETHUSDT', bybit: 'ETHUSDT' }, - XRP: { upbit: 'KRW-XRP', binance: 'XRPUSDT', bybit: 'XRPUSDT' }, - }; - const compareSymbol = compareExchange - ? COIN_SYMBOL_MAP[baseCoin.toUpperCase()]?.[compareExchange] - : ''; - const compareTickerKey = - compareExchange && compareSymbol ? `${compareExchange}:${compareSymbol}` : ''; - const compareTicker = useTickersStore((s) => - compareTickerKey ? s.tickers.get(compareTickerKey) : undefined, - ); - - // Create chart instance once useEffect(() => { if (!chartRef.current) return; @@ -88,26 +33,10 @@ export function CandleChart({ exchange, symbol, height = 400 }: CandleChartProps textColor: '#9ca3af', }, grid: { - vertLines: { color: 'rgba(243,244,246,0.1)' }, - horzLines: { color: 'rgba(243,244,246,0.1)' }, - }, - rightPriceScale: { borderVisible: false }, - timeScale: { borderVisible: false, timeVisible: true }, - localization: { - timeFormatter: (t: number) => { - // timestamp already has tz offset applied, use UTC methods to avoid double conversion - const d = new Date(t * 1000); - const month = d.getUTCMonth() + 1; - const day = d.getUTCDate(); - const hours = String(d.getUTCHours()).padStart(2, '0'); - const mins = String(d.getUTCMinutes()).padStart(2, '0'); - return `${month}. ${day}. ${hours}:${mins}`; - }, - }, - crosshair: { - horzLine: { visible: true, labelVisible: true }, - vertLine: { visible: true, labelVisible: true }, + vertLines: { color: 'rgba(156, 163, 175, 0.1)' }, + horzLines: { color: 'rgba(156, 163, 175, 0.1)' }, }, + timeScale: { timeVisible: true, secondsVisible: false }, }); const candleSeries = chart.addCandlestickSeries({ @@ -120,10 +49,10 @@ export function CandleChart({ exchange, symbol, height = 400 }: CandleChartProps }); const volumeSeries = chart.addHistogramSeries({ - priceFormat: { type: 'volume' }, + color: '#94a3b8', priceScaleId: 'volume', + priceFormat: { type: 'volume' }, }); - chart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.8, bottom: 0 }, }); @@ -133,8 +62,8 @@ export function CandleChart({ exchange, symbol, height = 400 }: CandleChartProps volumeSeriesRef.current = volumeSeries; const handleResize = () => { - if (chartRef.current) { - chart.applyOptions({ width: chartRef.current.clientWidth }); + if (chartRef.current && chartInstance.current) { + chartInstance.current.applyOptions({ width: chartRef.current.clientWidth }); } }; window.addEventListener('resize', handleResize); @@ -146,238 +75,48 @@ export function CandleChart({ exchange, symbol, height = 400 }: CandleChartProps candleSeriesRef.current = null; volumeSeriesRef.current = null; }; - }, [height]); // Only recreate on height change + }, [height]); - // Update data without recreating chart useEffect(() => { - if (!candleSeriesRef.current || !volumeSeriesRef.current || !candles || candles.length === 0) - return; - - // Apply local timezone offset so time axis labels show local time - const tzOffset = -new Date().getTimezoneOffset() * 60; // seconds (KST = +32400) + if (!candleSeriesRef.current || !volumeSeriesRef.current || !candles) return; const candleData = candles.map((c) => ({ - time: (c.timestamp / 1000 + tzOffset) as any, + time: Math.floor(c.timestamp / 1000) as never, open: Number(c.open), high: Number(c.high), low: Number(c.low), close: Number(c.close), })); - const volumeData = candles.map((c) => ({ - time: (c.timestamp / 1000 + tzOffset) as any, + time: Math.floor(c.timestamp / 1000) as never, value: Number(c.volume), - color: Number(c.close) >= Number(c.open) ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)', + color: Number(c.close) >= Number(c.open) ? '#22c55e80' : '#ef444480', })); candleSeriesRef.current.setData(candleData); volumeSeriesRef.current.setData(volumeData); }, [candles]); - // Fit content when new data arrives after interval change - const prevInterval = useRef(selectedInterval); - useEffect(() => { - if (chartInstance.current && candles && candles.length > 0) { - if (prevInterval.current !== selectedInterval) { - chartInstance.current.timeScale().fitContent(); - prevInterval.current = selectedInterval; - } - } - }, [candles, selectedInterval]); - - // Create/remove compare series on mode/exchange change only - useEffect(() => { - const chart = chartInstance.current; - if (!chart) return; - - if (compareSeriesRef.current) { - chart.removeSeries(compareSeriesRef.current); - compareSeriesRef.current = null; - } - - if (compareMode && compareExchange) { - const color = EXCHANGE_COLORS[compareExchange] || '#888'; - const lineSeries = chart.addLineSeries({ - color, - lineWidth: 2, - priceLineVisible: false, - lastValueVisible: true, - title: compareExchange, - }); - compareSeriesRef.current = lineSeries; - } - }, [compareMode, compareExchange]); - - // Update compare line data without recreating series - useEffect(() => { - if (!compareSeriesRef.current || !candles || candles.length === 0 || compareLines.length === 0) - return; - - const line = compareLines.find((l) => l.exchange === compareExchange); - if (!line || line.data.length === 0) return; - - const tzOff = -new Date().getTimezoneOffset() * 60; - const mainTimes = candles.map((c) => c.timestamp / 1000 + tzOff); - - const snappedData = line.data - .map((d) => { - const compareTime = d.time + tzOff; - let closest = mainTimes[0]; - let minDiff = Math.abs(compareTime - closest); - for (const mt of mainTimes) { - const diff = Math.abs(compareTime - mt); - if (diff < minDiff) { - minDiff = diff; - closest = mt; - } - } - return { time: closest as any, value: d.value }; - }) - .reduce((acc, item) => { - acc.set(item.time, item); - return acc; - }, new Map()); - - compareSeriesRef.current.setData( - Array.from(snappedData.values()).sort((a, b) => a.time - b.time), - ); - }, [compareLines, candles, compareExchange]); - - // Real-time compare line update — update last point instead of adding new time - useEffect(() => { - if ( - !compareSeriesRef.current || - !compareTicker || - !compareMode || - !candles || - candles.length === 0 - ) - return; - - let price = Number(compareTicker.price); - const currentIsKrw = exchange === 'upbit'; - const compareIsKrw = compareExchange === 'upbit'; - if (krwPerUsd > 0 && currentIsKrw !== compareIsKrw) { - price = currentIsKrw ? price * krwPerUsd : price / krwPerUsd; - } - - // Use last candle's time to keep within chart range - const tzOff = -new Date().getTimezoneOffset() * 60; - const lastTime = (candles[candles.length - 1].timestamp / 1000 + tzOff) as any; - compareSeriesRef.current.update({ time: lastTime, value: price }); - }, [compareTicker, compareMode, compareExchange, exchange, krwPerUsd, candles]); - - // Real-time ticker update - useEffect(() => { - if (!candleSeriesRef.current || !ticker || !candles || candles.length === 0) return; - - const lastCandle = candles[candles.length - 1]; - const price = Number(ticker.price); - const tzOff = -new Date().getTimezoneOffset() * 60; - const lastTime = (lastCandle.timestamp / 1000 + tzOff) as any; - - candleSeriesRef.current.update({ - time: lastTime, - open: Number(lastCandle.open), - high: Math.max(Number(lastCandle.high), price), - low: Math.min(Number(lastCandle.low), price), - close: price, - }); - }, [ticker, candles]); - return ( -
-
-
- {INTERVALS.map((iv) => ( - - ))} +
+
+ {INTERVALS.map((iv) => ( -
- {compareMode && ( -
-
- {otherExchanges.map((ex) => ( - - ))} -
- | -
- {PRICE_TYPES.map((pt) => ( - - ))} -
-
- )} - {ticker && ( - = 0 ? 'text-green-500' : 'text-red-500'}`} - > - {Number(ticker.price).toLocaleString()} ( - {Number(ticker.changePercent24h) > 0 ? '+' : ''} - {Number(ticker.changePercent24h).toFixed(2)}%) - - )} -
-
- {isLoading && ( -
- Loading chart... -
- )} -
+ ))}
+
+ {isLoading &&

Loading candles...

}
); } diff --git a/apps/web/src/components/flows/flow-canvas.tsx b/apps/web/src/components/flows/flow-canvas.tsx deleted file mode 100644 index 07f98a7..0000000 --- a/apps/web/src/components/flows/flow-canvas.tsx +++ /dev/null @@ -1,196 +0,0 @@ -'use client'; - -import { useCallback, useRef } from 'react'; -import { - ReactFlow, - Background, - Controls, - MiniMap, - type Connection, - type IsValidConnection, -} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; - -import { NODE_TYPE_REGISTRY, getRequiredConfig } from '@coin/types'; -import { useFlowStore, type FlowNodeData } from '@/stores/use-flow-store'; -import { customNodeTypes } from './nodes/base-node'; - -let dropCounter = 0; - -export function FlowCanvas() { - const nodes = useFlowStore((s) => s.nodes); - const edges = useFlowStore((s) => s.edges); - const onNodesChange = useFlowStore((s) => s.onNodesChange); - const onEdgesChange = useFlowStore((s) => s.onEdgesChange); - const onConnect = useFlowStore((s) => s.onConnect); - const setSelectedNode = useFlowStore((s) => s.setSelectedNode); - const addNode = useFlowStore((s) => s.addNode); - - const reactFlowWrapper = useRef(null); - - const handleNodeClick = useCallback( - (_: React.MouseEvent, node: { id: string }) => { - setSelectedNode(node.id); - }, - [setSelectedNode], - ); - - const handlePaneClick = useCallback(() => { - setSelectedNode(null); - }, [setSelectedNode]); - - const handleNodeDoubleClick = useCallback( - (_: React.MouseEvent, node: { id: string }) => { - setSelectedNode(node.id); - }, - [setSelectedNode], - ); - - // Validate connections by checking port type compatibility - const isValidConnection: IsValidConnection = useCallback( - ( - connection: - | Connection - | { - source: string; - target: string; - sourceHandle?: string | null; - targetHandle?: string | null; - }, - ) => { - const sourceNode = nodes.find((n) => n.id === connection.source); - const targetNode = nodes.find((n) => n.id === connection.target); - if (!sourceNode || !targetNode) return false; - - const sourceReg = NODE_TYPE_REGISTRY[sourceNode.data.subtype]; - const targetReg = NODE_TYPE_REGISTRY[targetNode.data.subtype]; - if (!sourceReg || !targetReg) return false; - - const sourcePort = sourceReg.outputs.find( - (p) => p.name === (connection.sourceHandle || sourceReg.outputs[0]?.name), - ); - const targetPort = targetReg.inputs.find( - (p) => p.name === (connection.targetHandle || targetReg.inputs[0]?.name), - ); - - if (!sourcePort || !targetPort) return false; - return sourcePort.type === targetPort.type; - }, - [nodes], - ); - - // Handle drag & drop from palette - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - }, []); - - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - const subtype = e.dataTransfer.getData('application/reactflow-subtype'); - if (!subtype) return; - - const info = NODE_TYPE_REGISTRY[subtype]; - if (!info) return; - - const bounds = reactFlowWrapper.current?.getBoundingClientRect(); - if (!bounds) return; - - const position = { - x: e.clientX - bounds.left - 80, - y: e.clientY - bounds.top - 20, - }; - - addNode({ - id: `${subtype}-drop-${++dropCounter}`, - type: info.type, - position, - data: { - label: info.label, - subtype: info.subtype, - nodeType: info.type, - config: getRequiredConfig(info), - }, - }); - }, - [addNode], - ); - - const isEmpty = nodes.length === 0; - - return ( -
- {/* Empty canvas guide overlay */} - {isEmpty && ( -
-
-
🔗
-

플로우를 시작해보세요

-

- 왼쪽 패널에서 노드를 클릭하거나 캔버스로 드래그해 추가하세요. -

-
-
- - 데이터 노드로 시작 (예: 캔들 데이터) -
-
- - 지표 노드로 RSI, MACD 등 계산 -
-
- - 조건 노드로 매매 신호 정의 -
-
- - 주문 노드로 자동 매매 실행 -
-
-
-
- )} - - - - { - const type = (n.data as FlowNodeData)?.nodeType; - if (type === 'data') return '#3b82f6'; - if (type === 'indicator') return '#8b5cf6'; - if (type === 'condition') return '#f59e0b'; - if (type === 'order') return '#10b981'; - return '#64748b'; - }} - style={{ backgroundColor: 'var(--color-background)' }} - maskColor="rgba(0,0,0,0.6)" - /> - -
- ); -} diff --git a/apps/web/src/components/flows/flow-card.tsx b/apps/web/src/components/flows/flow-card.tsx deleted file mode 100644 index 1524485..0000000 --- a/apps/web/src/components/flows/flow-card.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useTranslations } from 'next-intl'; -import { Trash2, Workflow } from 'lucide-react'; -import type { FlowItem } from '@/lib/api-client'; - -const STATUS_STYLES: Record = { - pending: 'bg-yellow-900/30 text-yellow-400', - running: 'bg-blue-900/30 text-blue-400', - completed: 'bg-green-900/30 text-green-400', - failed: 'bg-red-900/30 text-red-400', -}; - -interface FlowCardProps { - flow: FlowItem; - onDelete: () => void; -} - -export function FlowCard({ flow, onDelete }: FlowCardProps) { - const t = useTranslations('flows'); - const latestBacktest = flow.backtests?.[0]; - const nodeCount = flow.definition?.nodes?.length || 0; - - return ( - -
-
- -
-

{flow.name}

-

- {flow.exchange} · {flow.symbol} · {flow.candleInterval} -

-
-
- -
- -
- {nodeCount} nodes - · - - {flow.enabled ? 'ON' : 'OFF'} - - · - {flow.tradingMode === 'paper' ? '모의' : '실전'} -
- - {/* Latest backtest */} - {latestBacktest ? ( -
- - {t(latestBacktest.status as 'pending' | 'running' | 'completed' | 'failed')} - - {latestBacktest.summary && latestBacktest.summary.winRate != null && ( - - Win {(latestBacktest.summary.winRate * 100).toFixed(0)}% · PnL{' '} - = 0 ? 'text-green-400' : 'text-red-400' - } - > - {latestBacktest.summary.realizedPnl >= 0 ? '+' : ''} - {latestBacktest.summary.realizedPnl.toFixed(2)} - - - )} -
- ) : ( -

{t('noBacktest')}

- )} - - ); -} diff --git a/apps/web/src/components/flows/flow-toolbar.tsx b/apps/web/src/components/flows/flow-toolbar.tsx deleted file mode 100644 index fb8d4b8..0000000 --- a/apps/web/src/components/flows/flow-toolbar.tsx +++ /dev/null @@ -1,112 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import { ArrowLeft, Save, Play, Loader2 } from 'lucide-react'; -import { useFlowStore } from '@/stores/use-flow-store'; -import { useToastStore } from '@/stores/use-toast-store'; -import { updateFlow, requestBacktest } from '@/lib/api-client'; -import { useQueryClient } from '@tanstack/react-query'; - -export function FlowToolbar() { - const t = useTranslations('flows'); - const router = useRouter(); - const queryClient = useQueryClient(); - const addToast = useToastStore((s) => s.addToast); - const flowId = useFlowStore((s) => s.flowId); - const flowName = useFlowStore((s) => s.flowName); - const isDirty = useFlowStore((s) => s.isDirty); - const toDefinition = useFlowStore((s) => s.toDefinition); - const markClean = useFlowStore((s) => s.markClean); - const setFlowName = useFlowStore((s) => s.setFlowName); - const setActiveBacktest = useFlowStore((s) => s.setActiveBacktest); - const backtestStatus = useFlowStore((s) => s.backtestStatus); - - const [saving, setSaving] = useState(false); - - const handleSave = async () => { - if (!flowId) return; - setSaving(true); - try { - await updateFlow(flowId, { - name: flowName, - definition: toDefinition(), - }); - markClean(); - queryClient.invalidateQueries({ queryKey: ['flow', flowId] }); - queryClient.invalidateQueries({ queryKey: ['flows'] }); - addToast({ type: 'success', title: t('saved'), message: t('saved') }); - } catch (err: any) { - addToast({ type: 'error', title: t('saveFailed'), message: err.message }); - } finally { - setSaving(false); - } - }; - - const handleBacktest = async () => { - if (!flowId) return; - if (isDirty) await handleSave(); - - try { - const endDate = new Date(); - const startDate = new Date(); - startDate.setDate(startDate.getDate() - 30); - - const { backtestId } = await requestBacktest(flowId, { - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), - }); - setActiveBacktest(backtestId, 'pending'); - queryClient.invalidateQueries({ queryKey: ['backtests', flowId] }); - addToast({ - type: 'info', - title: t('backtestStarted'), - message: t('backtestStartedDesc'), - }); - } catch (err: any) { - addToast({ type: 'error', title: t('backtestFailed'), message: err.message }); - } - }; - - const backtestRunning = backtestStatus === 'pending' || backtestStatus === 'running'; - - return ( -
-
- - setFlowName(e.target.value)} - className="border-b border-transparent bg-transparent text-sm font-medium text-foreground outline-none focus:border-primary" - placeholder={t('flowName')} - /> - {isDirty && {t('modified')}} -
-
- - -
-
- ); -} diff --git a/apps/web/src/components/flows/node-help-data.ts b/apps/web/src/components/flows/node-help-data.ts deleted file mode 100644 index 0b55da5..0000000 --- a/apps/web/src/components/flows/node-help-data.ts +++ /dev/null @@ -1,91 +0,0 @@ -export interface NodeHelpInfo { - description: string; - usageExample: string; - paramHints?: Record; -} - -export const NODE_HELP: Record = { - 'candle-stream': { - description: - '거래소에서 실시간 캔들(OHLCV) 데이터를 가져옵니다. 모든 지표 노드의 시작점입니다.', - usageExample: '캔들 데이터 → RSI 지표 → 기준값 조건 → 시장가 주문', - }, - rsi: { - description: - 'RSI(상대강도지수)를 계산합니다. 0~100 범위의 값을 출력하며, 30 이하는 과매도, 70 이상은 과매수 신호로 활용합니다.', - usageExample: 'RSI 값 < 30 이면 매수 신호로 사용 (기준값 조건 노드에 연결)', - paramHints: { - period: '계산에 사용할 캔들 개수. 기본값 14.', - source: '가격 기준 (종가/시가/고가/저가).', - }, - }, - macd: { - description: - 'MACD(이동평균수렴확산)를 계산합니다. MACD 선이 시그널 선을 상향 돌파하면 매수, 하향 돌파하면 매도 신호로 활용합니다.', - usageExample: 'MACD 값과 시그널 값을 크로스 조건 노드에 연결하여 골든크로스/데드크로스 감지', - paramHints: { - fastPeriod: '단기 EMA 기간. 기본값 12.', - slowPeriod: '장기 EMA 기간. 기본값 26.', - signalPeriod: '시그널 선 기간. 기본값 9.', - }, - }, - bollinger: { - description: - '볼린저 밴드를 계산합니다. 상단/중간/하단 밴드를 출력하며 가격 변동성과 추세를 파악할 때 사용합니다.', - usageExample: '현재 가격이 하단 밴드 아래로 떨어지면 매수 신호로 활용 (크로스 조건 연결)', - paramHints: { - period: '이동평균 기간. 기본값 20.', - stdDev: '표준편차 배수. 기본값 2 (±2σ 범위).', - }, - }, - ema: { - description: - 'EMA(지수이동평균)를 계산합니다. 최근 데이터에 더 많은 가중치를 부여해 빠른 추세 변화를 감지합니다.', - usageExample: '단기 EMA(9)와 장기 EMA(21)를 크로스 조건에 연결해 추세 전환 감지', - paramHints: { - period: '평균 계산에 사용할 캔들 개수.', - }, - }, - threshold: { - description: - '숫자 값을 기준값과 비교해 조건의 참/거짓을 출력합니다. 지표 값이 특정 임계치를 넘는지 확인할 때 사용합니다.', - usageExample: 'RSI > 70 이면 과매수 신호 → 매도 주문 트리거', - paramHints: { - operator: '비교 연산자: < (미만), > (초과), <= (이하), >= (이상), == (같음).', - threshold: '비교할 기준 숫자 값.', - }, - }, - crossover: { - description: - '두 숫자 값의 크로스오버(상향/하향 돌파)를 감지합니다. 값 A가 값 B를 돌파하는 순간만 참을 출력합니다.', - usageExample: 'MACD 값이 시그널 값을 상향 돌파할 때 매수 신호 발생', - paramHints: { - direction: '상향 돌파(above): A가 B를 위로 돌파 / 하향 돌파(below): A가 B를 아래로 돌파.', - }, - }, - 'and-or': { - description: - '두 조건을 AND 또는 OR 논리로 결합합니다. 여러 조건을 동시에 만족해야 하거나(AND), 하나라도 만족하면 될 때(OR) 사용합니다.', - usageExample: 'RSI < 30 AND MACD 골든크로스 → 두 조건 모두 만족할 때만 매수', - paramHints: { - operator: 'AND: 두 조건 모두 참일 때 / OR: 하나라도 참일 때.', - }, - }, - 'market-order': { - description: - '조건이 참일 때 시장가 주문을 실행합니다. 현재 시장 가격으로 즉시 매수 또는 매도합니다.', - usageExample: '조건 노드 결과 → 시장가 주문 (매수 방향, 수량 0.001 BTC)', - paramHints: { - side: '매수(buy) 또는 매도(sell).', - amount: '주문 수량 (코인 단위, 예: 0.001).', - }, - }, - alert: { - description: - '조건이 참일 때 알림 메시지를 전송합니다. 실제 주문 없이 신호 발생 여부만 확인할 때 사용합니다.', - usageExample: '조건 만족 시 "RSI 과매도 신호 발생!" 알림 전송', - paramHints: { - message: '알림으로 전송할 메시지 내용.', - }, - }, -}; diff --git a/apps/web/src/components/flows/node-inspector.tsx b/apps/web/src/components/flows/node-inspector.tsx deleted file mode 100644 index 4cfeb6f..0000000 --- a/apps/web/src/components/flows/node-inspector.tsx +++ /dev/null @@ -1,288 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import { useTranslations } from 'next-intl'; -import { useFlowStore } from '@/stores/use-flow-store'; -import { NODE_TYPE_REGISTRY } from '@coin/types'; -import { Trash2 } from 'lucide-react'; - -const PARAM_LABELS: Record = { - period: '기간', - source: '기준가', - fastPeriod: '단기 기간', - slowPeriod: '장기 기간', - signalPeriod: '시그널 기간', - stdDev: '표준편차 배수', - operator: '연산자', - threshold: '기준값', - direction: '방향', - side: '매매 방향', - amount: '수량', - message: '알림 메시지', - overbought: '과매수 기준', - oversold: '과매도 기준', -}; - -const PARAM_VALUE_LABELS: Record = { - buy: '매수', - sell: '매도', - above: '상향 돌파 (골든크로스)', - below: '하향 돌파 (데드크로스)', - AND: 'AND (모두 참일 때)', - OR: 'OR (하나라도 참일 때)', - close: '종가', - open: '시가', - high: '고가', - low: '저가', -}; - -function ParamInput({ - paramKey, - value, - onChange, - options, -}: { - paramKey: string; - value: unknown; - onChange: (val: unknown) => void; - options?: string[]; -}) { - if (options && options.length > 0) { - return ( - - ); - } - if (typeof value === 'boolean') { - return ( - - ); - } - return ( - - onChange(typeof value === 'number' ? Number(e.target.value) || 0 : e.target.value) - } - className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground outline-none focus:border-primary" - placeholder={PARAM_VALUE_LABELS[String(value)] ?? String(value)} - /> - ); -} - -export function NodeInspector() { - const t = useTranslations('flows'); - const nodes = useFlowStore((s) => s.nodes); - const selectedNodeId = useFlowStore((s) => s.selectedNodeId); - const updateNodeConfig = useFlowStore((s) => s.updateNodeConfig); - const deleteNode = useFlowStore((s) => s.deleteNode); - const traceData = useFlowStore((s) => s.traceData); - const timelineIndex = useFlowStore((s) => s.timelineIndex); - const backtestStatus = useFlowStore((s) => s.backtestStatus); - - const node = nodes.find((n) => n.id === selectedNodeId); - - const currentTrace = useMemo(() => { - if (!node || backtestStatus !== 'completed' || traceData.length === 0) return null; - const timestamps = [...new Set(traceData.map((t) => t.timestamp))].sort(); - const currentTs = timestamps[timelineIndex]; - if (!currentTs) return null; - return traceData.find((t) => t.nodeId === node.id && t.timestamp === currentTs) ?? null; - }, [node, traceData, timelineIndex, backtestStatus]); - - if (!node) { - return ( -
-

{t('selectNode')}

-
- ); - } - - const registry = NODE_TYPE_REGISTRY[node.data.subtype]; - const config = node.data.config || {}; - const params = registry?.params; - - const requiredParams = params?.filter((p) => p.required) ?? []; - const optionalParams = params?.filter((p) => !p.required) ?? []; - - return ( -
- {/* Header */} -
-
-

- {registry?.label || node.data.subtype} -

-

{node.id}

-
- -
- - {/* Parameters */} -
-

- {t('parameters')} -

- - {params ? ( -
- {/* Required params — always shown */} - {requiredParams.map(({ key, options }) => ( - - ))} - - {/* Optional params — toggle to enable/disable */} - {optionalParams.length > 0 && ( - <> -
- - 선택적 파라미터 - -
- {optionalParams.map(({ key, options }) => { - const isEnabled = key in config; - const defaultVal = registry.defaultConfig[key]; - return ( -
- - {isEnabled ? ( - updateNodeConfig(node.id, { [key]: val })} - options={options} - /> - ) : ( - - 기본값: {PARAM_VALUE_LABELS[String(defaultVal)] ?? String(defaultVal)} - - )} -
- ); - })} - - )} -
- ) : ( - /* Fallback: no params metadata — render all config keys as before */ -
- {Object.entries(config).map(([key, val]) => ( - - ))} -
- )} - - {/* Ports info */} - {registry && ( -
-

- {t('ports')} -

- {registry.inputs.length > 0 && ( -
- {t('inputs')}: - {registry.inputs.map((p) => ( - - {p.name}({p.type}) - - ))} -
- )} - {registry.outputs.length > 0 && ( -
- {t('outputs')}: - {registry.outputs.map((p) => ( - - {p.name}({p.type}) - - ))} -
- )} -
- )} - - {/* Execution trace */} - {currentTrace && ( -
-

- {t('executionTrace')} -

-
-
fired: {String(currentTrace.fired)}
-
duration: {currentTrace.durationMs}ms
-
- output: {JSON.stringify(currentTrace.output, null, 1)} -
-
-
- )} -
-
- ); -} diff --git a/apps/web/src/components/flows/node-palette.tsx b/apps/web/src/components/flows/node-palette.tsx deleted file mode 100644 index c0d2c0c..0000000 --- a/apps/web/src/components/flows/node-palette.tsx +++ /dev/null @@ -1,86 +0,0 @@ -'use client'; - -import { useCallback } from 'react'; -import { NODE_TYPE_REGISTRY, getRequiredConfig } from '@coin/types'; -import type { NodeTypeInfo } from '@coin/types'; -import { useFlowStore } from '@/stores/use-flow-store'; - -const CATEGORIES: { type: string; label: string; color: string }[] = [ - { type: 'data', label: '데이터', color: 'text-blue-400' }, - { type: 'indicator', label: '지표', color: 'text-purple-400' }, - { type: 'condition', label: '조건', color: 'text-amber-400' }, - { type: 'order', label: '주문', color: 'text-emerald-400' }, -]; - -const grouped = CATEGORIES.map((cat) => ({ - ...cat, - items: Object.values(NODE_TYPE_REGISTRY).filter((n) => n.type === cat.type), -})); - -let nodeIdCounter = 0; - -export function NodePalette() { - const addNode = useFlowStore((s) => s.addNode); - - const handleAdd = useCallback( - (info: NodeTypeInfo) => { - const id = `${info.subtype}-${++nodeIdCounter}`; - addNode({ - id, - type: info.type, - position: { x: 250 + Math.random() * 100, y: 150 + Math.random() * 100 }, - data: { - label: info.label, - subtype: info.subtype, - nodeType: info.type, - config: getRequiredConfig(info), - }, - }); - }, - [addNode], - ); - - const onDragStart = useCallback((e: React.DragEvent, info: NodeTypeInfo) => { - e.dataTransfer.setData('application/reactflow-subtype', info.subtype); - e.dataTransfer.effectAllowed = 'move'; - }, []); - - return ( -
-

노드

- {grouped.map((cat) => ( -
-

- {cat.label} -

-
- {cat.items.map((info) => ( - - ))} -
-
- ))} -
- ); -} diff --git a/apps/web/src/components/flows/nodes/base-node.tsx b/apps/web/src/components/flows/nodes/base-node.tsx deleted file mode 100644 index 67de447..0000000 --- a/apps/web/src/components/flows/nodes/base-node.tsx +++ /dev/null @@ -1,444 +0,0 @@ -'use client'; - -import { memo, useMemo, useState } from 'react'; -import { Handle, Position } from '@xyflow/react'; -import type { NodeProps } from '@xyflow/react'; -import { CheckCircle2, XCircle, HelpCircle } from 'lucide-react'; -import { NODE_TYPE_REGISTRY } from '@coin/types'; -import type { PortDefinition } from '@coin/types'; -import { useFlowStore, type FlowNodeData } from '@/stores/use-flow-store'; -import { NODE_HELP } from '../node-help-data'; - -// Color per port data type — used on handles and type badges -export const PORT_TYPE_COLORS: Record = { - 'Candle[]': '#f59e0b', - 'OrderBookLevel[]': '#8b5cf6', - number: '#06b6d4', - boolean: '#22c55e', - 'boolean[]': '#10b981', - OrderResult: '#f97316', -}; - -// Korean display labels for port types -const PORT_TYPE_LABELS: Record = { - 'Candle[]': '캔들', - 'OrderBookLevel[]': '호가창', - number: '숫자', - boolean: '불리언', - 'boolean[]': '불리언[]', - OrderResult: '주문결과', -}; - -// Korean display labels for port names -const PORT_NAME_LABELS: Record = { - candles: '캔들', - value: '값', - macd: 'MACD 값', - signal: '시그널 값', - histogram: '히스토그램', - upper: '상단 밴드', - middle: '중간 밴드', - lower: '하단 밴드', - result: '조건 결과', - trigger: '트리거', - a: '조건 A', - b: '조건 B', - value_a: '비교값 A', - value_b: '기준값 B', -}; - -// Korean display labels for config parameter keys -const PARAM_LABELS: Record = { - period: '기간', - source: '기준가', - fastPeriod: '단기 기간', - slowPeriod: '장기 기간', - signalPeriod: '시그널 기간', - stdDev: '표준편차 배수', - operator: '연산자', - threshold: '기준값', - direction: '방향', - side: '매매 방향', - amount: '수량', - message: '알림 메시지', -}; - -// Korean display labels for config parameter values -const PARAM_VALUE_LABELS: Record = { - buy: '매수', - sell: '매도', - above: '상향 돌파', - below: '하향 돌파', - AND: 'AND', - OR: 'OR', - close: '종가', - open: '시가', - high: '고가', - low: '저가', -}; - -const NODE_STYLES: Record = { - data: { border: 'border-blue-500', headerBg: 'bg-blue-900/60', dot: 'bg-blue-400' }, - indicator: { border: 'border-purple-500', headerBg: 'bg-purple-900/60', dot: 'bg-purple-400' }, - condition: { border: 'border-amber-500', headerBg: 'bg-amber-900/60', dot: 'bg-amber-400' }, - order: { border: 'border-emerald-500', headerBg: 'bg-emerald-900/60', dot: 'bg-emerald-400' }, - 'flow-control': { border: 'border-slate-500', headerBg: 'bg-slate-900/60', dot: 'bg-slate-400' }, -}; - -// Inline help popover shown when ? button is clicked -function NodeHelpPopover({ subtype, onClose }: { subtype: string; onClose: () => void }) { - const help = NODE_HELP[subtype]; - if (!help) return null; - - return ( -
e.stopPropagation()} - > -
- - {help.description} - - -
- - {help.paramHints && Object.keys(help.paramHints).length > 0 && ( -
-
- 파라미터 -
- {Object.entries(help.paramHints).map(([key, hint]) => ( -
- - {PARAM_LABELS[key] ?? key}:{' '} - - {hint} -
- ))} -
- )} - -
-
- 사용 예시 -
-

{help.usageExample}

-
-
- ); -} - -// A single input port row — handle on the left edge, label inside -function InputPort({ port, isConnected }: { port: PortDefinition; isConnected: boolean }) { - const [showTooltip, setShowTooltip] = useState(false); - const color = PORT_TYPE_COLORS[port.type] || '#64748b'; - const typeLabel = PORT_TYPE_LABELS[port.type] || port.type; - const showWarning = port.required && !isConnected; - - return ( -
setShowTooltip(true)} - onMouseLeave={() => setShowTooltip(false)} - > - - - {PORT_NAME_LABELS[port.name] ?? port.name} - - {port.required && *} - - {showTooltip && ( -
-
- {PORT_NAME_LABELS[port.name] ?? port.name} -
-
- {typeLabel} -
-
- {showWarning ? '입력이 연결되지 않았습니다' : port.required ? '필수 입력' : '선택 입력'} -
-
- )} -
- ); -} - -// A single output port row — handle on the right edge, label inside -function OutputPort({ port }: { port: PortDefinition }) { - const [showTooltip, setShowTooltip] = useState(false); - const color = PORT_TYPE_COLORS[port.type] || '#64748b'; - const typeLabel = PORT_TYPE_LABELS[port.type] || port.type; - - return ( -
setShowTooltip(true)} - onMouseLeave={() => setShowTooltip(false)} - > - - - {PORT_NAME_LABELS[port.name] ?? port.name} - - - {showTooltip && ( -
-
- {PORT_NAME_LABELS[port.name] ?? port.name} -
-
- {typeLabel} -
-
- )} -
- ); -} - -function BaseNode({ id, data, selected }: NodeProps & { data: FlowNodeData }) { - const [showHelp, setShowHelp] = useState(false); - const style = NODE_STYLES[data.nodeType] || NODE_STYLES.data; - const registry = NODE_TYPE_REGISTRY[data.subtype]; - const inputs = registry?.inputs || []; - const outputs = registry?.outputs || []; - - const traceData = useFlowStore((s) => s.traceData); - const timelineIndex = useFlowStore((s) => s.timelineIndex); - const backtestStatus = useFlowStore((s) => s.backtestStatus); - const edges = useFlowStore((s) => s.edges); - - // Build a set of connected target handles for this node - const connectedInputs = useMemo(() => { - const connected = new Set(); - for (const edge of edges) { - if (edge.target === id) { - connected.add(edge.targetHandle ?? ''); - } - } - return connected; - }, [edges, id]); - - const traceState = useMemo(() => { - if (backtestStatus !== 'completed' || traceData.length === 0) return null; - const timestamps = [...new Set(traceData.map((t) => t.timestamp))].sort(); - const currentTs = timestamps[timelineIndex]; - if (!currentTs) return null; - return traceData.find((t) => t.nodeId === id && t.timestamp === currentTs) ?? null; - }, [traceData, timelineIndex, backtestStatus, id]); - - let glowStyle = ''; - let traceValue: string | null = null; - if (traceState) { - glowStyle = traceState.fired - ? 'shadow-[0_0_20px_rgba(16,185,129,0.4)]' - : 'shadow-[0_0_20px_rgba(239,68,68,0.4)]'; - const outEntries = Object.entries(traceState.output); - if (outEntries.length > 0) { - const [, val] = outEntries[0]; - traceValue = - typeof val === 'number' - ? val.toFixed(2) - : typeof val === 'boolean' - ? String(val) - : val != null - ? String(val).slice(0, 12) - : null; - } - } - - const hasConfig = Object.keys(data.config || {}).length > 0; - const hasPorts = inputs.length > 0 || outputs.length > 0; - const hasHelp = !!NODE_HELP[data.subtype]; - - // Show a warning badge if any required input is unconnected - const missingRequiredInputs = inputs.filter((p) => p.required && !connectedInputs.has(p.name)); - - return ( -
- {/* Header */} -
- - - {registry?.label || data.subtype} - - -
- {/* Unconnected required inputs warning */} - {missingRequiredInputs.length > 0 && ( - PORT_NAME_LABELS[p.name] ?? p.name).join(', ')}`} - > - ⚠ - - )} - - {/* Trace result indicator */} - {traceState && ( - - {traceState.fired ? ( - - ) : ( - - )} - {traceState.durationMs}ms - - )} - - {/* Help button */} - {hasHelp && ( - - )} -
-
- - {/* Help popover */} - {showHelp && setShowHelp(false)} />} - - {/* Config preview or trace value */} - {traceValue != null ? ( -
- {traceValue} -
- ) : ( - hasConfig && ( -
- {Object.entries(data.config || {}) - .slice(0, 3) - .map(([key, val]) => ( -
- {PARAM_LABELS[key] ?? key} - - {PARAM_VALUE_LABELS[String(val)] ?? String(val)} - -
- ))} -
- ) - )} - - {/* Ports section */} - {hasPorts && ( -
-
- {/* Input ports */} -
- {inputs.length > 0 && ( - <> -
- 입력 -
- {inputs.map((input) => ( - - ))} - - )} -
- - {/* Output ports */} -
- {outputs.length > 0 && ( - <> -
- 출력 -
- {outputs.map((output) => ( - - ))} - - )} -
-
-
- )} -
- ); -} - -export const DataNode = memo(BaseNode); -export const IndicatorNode = memo(BaseNode); -export const ConditionNode = memo(BaseNode); -export const OrderNode = memo(BaseNode); -export const FlowControlNode = memo(BaseNode); - -export const customNodeTypes = { - data: DataNode, - indicator: IndicatorNode, - condition: ConditionNode, - order: OrderNode, - 'flow-control': FlowControlNode, -}; diff --git a/apps/web/src/components/flows/template-picker-modal.tsx b/apps/web/src/components/flows/template-picker-modal.tsx deleted file mode 100644 index ac82459..0000000 --- a/apps/web/src/components/flows/template-picker-modal.tsx +++ /dev/null @@ -1,127 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { X, Workflow, ChevronRight } from 'lucide-react'; -import { FLOW_TEMPLATES, type FlowTemplate } from '@/lib/flow-templates'; - -const DIFFICULTY_LABELS: Record = { - beginner: '초급', - intermediate: '중급', - advanced: '고급', -}; - -const DIFFICULTY_COLORS: Record = { - beginner: 'text-emerald-400 bg-emerald-400/10', - intermediate: 'text-amber-400 bg-amber-400/10', - advanced: 'text-red-400 bg-red-400/10', -}; - -interface TemplatePickerModalProps { - onSelect: (template: FlowTemplate | null) => void; - onClose: () => void; -} - -export function TemplatePickerModal({ onSelect, onClose }: TemplatePickerModalProps) { - const [hovered, setHovered] = useState(null); - - return ( -
-
- {/* Header */} -
-
-

플로우 시작하기

-

- 템플릿을 선택하거나 빈 플로우로 시작하세요. -

-
- -
- - {/* Template grid */} -
- {/* Empty flow card */} - - - {/* Template cards */} - {FLOW_TEMPLATES.map((template) => ( - - ))} -
-
-
- ); -} diff --git a/apps/web/src/components/flows/timeline-slider.tsx b/apps/web/src/components/flows/timeline-slider.tsx deleted file mode 100644 index 763e16e..0000000 --- a/apps/web/src/components/flows/timeline-slider.tsx +++ /dev/null @@ -1,100 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useRef, useState } from 'react'; -import { SkipBack, SkipForward, Play, Pause } from 'lucide-react'; -import { useFlowStore } from '@/stores/use-flow-store'; - -export function TimelineSlider() { - const traceData = useFlowStore((s) => s.traceData); - const timelineIndex = useFlowStore((s) => s.timelineIndex); - const setTimelineIndex = useFlowStore((s) => s.setTimelineIndex); - const backtestStatus = useFlowStore((s) => s.backtestStatus); - - const [playing, setPlaying] = useState(false); - const intervalRef = useRef | null>(null); - - // Group traces by unique timestamps - const timestamps = [...new Set(traceData.map((t) => t.timestamp))].sort(); - const maxIndex = Math.max(0, timestamps.length - 1); - - useEffect(() => { - if (playing && timelineIndex < maxIndex) { - intervalRef.current = setInterval(() => { - setTimelineIndex(Math.min(timelineIndex + 1, maxIndex)); - }, 500); - } else { - setPlaying(false); - } - return () => { - if (intervalRef.current) clearInterval(intervalRef.current); - }; - }, [playing, timelineIndex, maxIndex, setTimelineIndex]); - - const handlePlay = useCallback(() => { - if (timelineIndex >= maxIndex) { - setTimelineIndex(0); - } - setPlaying(true); - }, [timelineIndex, maxIndex, setTimelineIndex]); - - if (backtestStatus !== 'completed' || timestamps.length === 0) { - return null; - } - - const currentTs = timestamps[timelineIndex]; - const formattedDate = currentTs - ? new Date(currentTs).toLocaleString('ko-KR', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) - : ''; - - return ( -
- {/* Playback controls */} -
- - - -
- - {/* Slider */} - { - setPlaying(false); - setTimelineIndex(Number(e.target.value)); - }} - className="h-1 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-emerald-500" - /> - - {/* Timestamp display */} - - {formattedDate} ({timelineIndex + 1}/{timestamps.length}) - -
- ); -} diff --git a/apps/web/src/components/icons/exchange-icon.tsx b/apps/web/src/components/icons/exchange-icon.tsx index d7c4856..9b65cb9 100644 --- a/apps/web/src/components/icons/exchange-icon.tsx +++ b/apps/web/src/components/icons/exchange-icon.tsx @@ -10,9 +10,7 @@ interface ExchangeIconProps { } const EXCHANGE_LOGOS: Record = { - upbit: 'https://static.upbit.com/logos/UPBIT.png', binance: 'https://bin.bnbstatic.com/static/images/common/logo.png', - bybit: 'https://www.bybit.com/favicon.ico', }; function Fallback({ char, size }: { char: string; size: number }) { diff --git a/apps/web/src/components/markets/ticker-card-list.tsx b/apps/web/src/components/markets/ticker-card-list.tsx index 3331e5c..0bb2aba 100644 --- a/apps/web/src/components/markets/ticker-card-list.tsx +++ b/apps/web/src/components/markets/ticker-card-list.tsx @@ -1,15 +1,14 @@ 'use client'; -import { useState, useMemo, useRef } from 'react'; +import { useState, useMemo } from 'react'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; -import { Search, ShoppingCart } from 'lucide-react'; +import { Search } from 'lucide-react'; import type { Ticker } from '@coin/types'; import { CoinIcon, ExchangeIcon } from '@/components/icons'; import { useExchangeRate } from '@/hooks/use-exchange-rate'; import { useBaseCurrency } from '@/hooks/use-base-currency'; import { formatPrice } from '@/lib/utils'; -import { QuickOrderPanel } from '@/components/orders/quick-order-panel'; interface TickerCardListProps { tickers: Ticker[]; @@ -17,31 +16,13 @@ interface TickerCardListProps { function getDisplayPrices( price: string, - exchange: string, krwPerUsd: number, baseCurrency: 'KRW' | 'USD', ): { main: string; sub: string | null } { const num = Number(price); - if (!krwPerUsd) return { main: formatPrice(price), sub: null }; + if (!krwPerUsd) return { main: `$${formatPrice(price)}`, sub: null }; - const isKrwExchange = exchange === 'upbit'; - const isBaseKrw = baseCurrency === 'KRW'; - - if (isKrwExchange && isBaseKrw) { - const usd = num / krwPerUsd; - return { - main: `₩${formatPrice(price)}`, - sub: `$${usd >= 1 ? usd.toLocaleString('en-US', { maximumFractionDigits: 2 }) : usd.toLocaleString('en-US', { maximumFractionDigits: 6 })}`, - }; - } - if (isKrwExchange && !isBaseKrw) { - const usd = num / krwPerUsd; - return { - main: `$${usd >= 1 ? usd.toLocaleString('en-US', { maximumFractionDigits: 2 }) : usd.toLocaleString('en-US', { maximumFractionDigits: 6 })}`, - sub: `₩${formatPrice(price)}`, - }; - } - if (!isKrwExchange && isBaseKrw) { + if (baseCurrency === 'KRW') { const krw = num * krwPerUsd; return { main: `₩${krw.toLocaleString('ko-KR', { maximumFractionDigits: 0 })}`, @@ -51,132 +32,58 @@ function getDisplayPrices( return { main: `$${formatPrice(price)}`, sub: null }; } -const SWIPE_THRESHOLD = 60; - -function SwipableTickerCard({ - ticker, - onQuickOrder, -}: { - ticker: Ticker; - onQuickOrder: (ticker: Ticker) => void; -}) { +function TickerCard({ ticker }: { ticker: Ticker }) { const { krwPerUsd } = useExchangeRate(); const { currency: baseCurrency } = useBaseCurrency(); - const touchStartX = useRef(null); - const [offsetX, setOffsetX] = useState(0); - const [swiped, setSwiped] = useState(false); - const changeNum = Number(ticker.changePercent24h); const changeColor = changeNum > 0 ? 'text-green-500' : changeNum < 0 ? 'text-red-500' : 'text-muted-foreground'; const { main: mainPrice, sub: subPrice } = getDisplayPrices( ticker.price, - ticker.exchange, krwPerUsd, baseCurrency, ); - const handleTouchStart = (e: React.TouchEvent) => { - touchStartX.current = e.touches[0].clientX; - setSwiped(false); - }; - - const handleTouchMove = (e: React.TouchEvent) => { - if (touchStartX.current === null) return; - const delta = e.touches[0].clientX - touchStartX.current; - // Only allow left swipe (negative delta) - if (delta < 0) { - setOffsetX(Math.max(delta, -96)); - } else if (swiped) { - setOffsetX(Math.min(0, -96 + delta)); - } - }; - - const handleTouchEnd = () => { - if (offsetX < -SWIPE_THRESHOLD) { - setOffsetX(-80); - setSwiped(true); - } else { - setOffsetX(0); - setSwiped(false); - } - touchStartX.current = null; - }; - return ( -
- {/* Swipe action button */} -
- -
- - {/* Card content */} -
- { - // Don't navigate if card is swiped - if (swiped || offsetX < -10) e.preventDefault(); - }} - > -
-
- -
-
{ticker.symbol}
-
- - {ticker.exchange} -
-
-
-
-
{mainPrice}
- {subPrice && ( -
{subPrice}
- )} + +
+
+ +
+
{ticker.symbol}
+
+ + {ticker.exchange}
+
+
+
{mainPrice}
+ {subPrice &&
{subPrice}
} +
+
-
- - {changeNum > 0 ? '+' : ''} - {changeNum.toFixed(2)}% - - - H {formatPrice(ticker.high24h)} · L {formatPrice(ticker.low24h)} - -
- +
+ + {changeNum > 0 ? '+' : ''} + {changeNum.toFixed(2)}% + + + H {formatPrice(ticker.high24h)} · L {formatPrice(ticker.low24h)} +
-
+ ); } export function TickerCardList({ tickers }: TickerCardListProps) { const t = useTranslations('ticker'); const [filter, setFilter] = useState(''); - const [quickOrderTicker, setQuickOrderTicker] = useState(null); const filtered = useMemo(() => { if (!filter) return tickers; @@ -208,18 +115,11 @@ export function TickerCardList({ tickers }: TickerCardListProps) {

) : (
-

← Swipe left for quick order

{filtered.map((ticker) => ( - + ))}
)} - - setQuickOrderTicker(null)} />
); } diff --git a/apps/web/src/components/mobile-tab-bar.tsx b/apps/web/src/components/mobile-tab-bar.tsx index 50f094b..2bbad7d 100644 --- a/apps/web/src/components/mobile-tab-bar.tsx +++ b/apps/web/src/components/mobile-tab-bar.tsx @@ -6,8 +6,6 @@ import { usePathname } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { BarChart3, - ShoppingCart, - BrainCircuit, PieChart, MoreHorizontal, Activity, @@ -20,8 +18,6 @@ import { isDemo } from '@/lib/demo'; const TABS = [ { href: '/markets', icon: BarChart3, labelKey: 'markets' as const }, - { href: '/orders', icon: ShoppingCart, labelKey: 'orders' as const }, - { href: '/strategies', icon: BrainCircuit, labelKey: 'strategies' as const }, { href: '/portfolio', icon: PieChart, labelKey: 'portfolio' as const }, ]; diff --git a/apps/web/src/components/nav-bar.tsx b/apps/web/src/components/nav-bar.tsx index b4d188f..16d308c 100644 --- a/apps/web/src/components/nav-bar.tsx +++ b/apps/web/src/components/nav-bar.tsx @@ -4,9 +4,6 @@ import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { BarChart3, - ShoppingCart, - BrainCircuit, - Workflow, PieChart, Activity, Settings, @@ -64,27 +61,6 @@ export function NavBar() { 대시보드 - - - {t('orders')} - - - - {t('strategies')} - - - - {t('flows')} - = { order_submitted: ShoppingCart, order_cancelled: ShoppingCart, order_failed: AlertTriangle, - strategy_signal: BrainCircuit, position_opened: TrendingUp, position_closed: TrendingDown, info: Info, @@ -35,7 +25,6 @@ const EVENT_COLOR: Record = { order_submitted: 'text-blue-500', order_cancelled: 'text-muted-foreground', order_failed: 'text-red-500', - strategy_signal: 'text-purple-500', position_opened: 'text-green-500', position_closed: 'text-orange-500', info: 'text-blue-500', diff --git a/apps/web/src/components/orders/live-price.tsx b/apps/web/src/components/orders/live-price.tsx deleted file mode 100644 index dbc642b..0000000 --- a/apps/web/src/components/orders/live-price.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import type { Ticker } from '@coin/types'; -import { formatPrice } from '@/lib/utils'; - -export function LivePrice({ ticker }: { ticker: Ticker }) { - const prevPrice = useRef(ticker.price); - const [flash, setFlash] = useState<'up' | 'down' | null>(null); - - useEffect(() => { - if (ticker.price !== prevPrice.current) { - const direction = Number(ticker.price) > Number(prevPrice.current) ? 'up' : 'down'; - setFlash(direction); - prevPrice.current = ticker.price; - const timer = setTimeout(() => setFlash(null), 300); - return () => clearTimeout(timer); - } - }, [ticker.price]); - - const changeNum = Number(ticker.changePercent24h); - const changeColor = - changeNum > 0 ? 'text-green-500' : changeNum < 0 ? 'text-red-500' : 'text-muted-foreground'; - - return ( -
- - {formatPrice(ticker.price)} - - - {changeNum > 0 ? '+' : ''} - {changeNum.toFixed(2)}% - -
- ); -} diff --git a/apps/web/src/components/orders/order-form.tsx b/apps/web/src/components/orders/order-form.tsx deleted file mode 100644 index ddd787c..0000000 --- a/apps/web/src/components/orders/order-form.tsx +++ /dev/null @@ -1,300 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { useMutation } from '@tanstack/react-query'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; -import { Dialog } from '@/components/ui/dialog'; -import { createOrder, type ExchangeKeyItem } from '@/lib/api-client'; -import { useOrderForm } from '@/hooks/use-order-form'; -import { useTranslations } from 'next-intl'; -import type { Ticker } from '@coin/types'; -import { ExchangeIcon } from '@/components/icons'; -import { formatPrice } from '@/lib/utils'; -import { LivePrice } from './live-price'; -import { SymbolCard } from './symbol-card'; - -export function OrderForm({ - keys, - tickers, - onSuccess, - onSelectionChange, -}: { - keys: ExchangeKeyItem[]; - tickers: Ticker[]; - onSuccess: () => void; - onSelectionChange?: (exchange: string, symbol: string) => void; -}) { - const [exchange, setExchange] = useState(''); - const [symbol, setSymbol] = useState(''); - const [side, setSide] = useState<'buy' | 'sell'>('buy'); - const [type, setType] = useState<'market' | 'limit'>('market'); - const [quantity, setQuantity] = useState(''); - const [price, setPrice] = useState(''); - const [mode, setMode] = useState<'paper' | 'real'>('paper'); - const [error, setError] = useState(''); - const [showRealConfirm, setShowRealConfirm] = useState(false); - const t = useTranslations('orders'); - - const { exchangeKeyId, quoteBalance, quoteCurrency, activeExchanges, activeSymbols } = - useOrderForm({ exchange, mode, keys, tickers }); - - // 선택된 심볼의 실시간 티커 - const selectedTicker = tickers.find((tk) => tk.exchange === exchange && tk.symbol === symbol); - - // Notify parent of selection - useEffect(() => { - onSelectionChange?.(exchange, symbol); - }, [exchange, symbol, onSelectionChange]); - - // 거래소 변경 시 심볼 초기화 - useEffect(() => { - setSymbol(''); - }, [exchange]); - - const mutation = useMutation({ - mutationFn: createOrder, - onSuccess: () => { - onSuccess(); - setQuantity(''); - setPrice(''); - setError(''); - }, - onError: (err: Error) => setError(err.message), - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - mutation.mutate({ - exchange, - symbol, - side, - type, - quantity, - ...(type === 'limit' ? { price } : {}), - mode, - ...(mode === 'real' ? { exchangeKeyId } : {}), - }); - }; - - return ( - - - {t('newOrder')} - - -
- {/* Mode toggle */} -
- - -
- - {/* Balance display */} - {mode === 'real' && exchange && quoteBalance && ( -
- {quoteCurrency} 잔고 - - {parseFloat(quoteBalance.free).toLocaleString('ko-KR', { - maximumFractionDigits: 2, - })}{' '} - {quoteCurrency} - -
- )} - - {/* Exchange */} -
- -
- {activeExchanges.map((ex) => ( - - ))} -
-
- - {/* Symbol selection - card grid with live prices */} - {exchange && ( -
- -
- {activeSymbols.map((tk) => ( - setSymbol(tk.symbol)} - /> - ))} -
-
- )} - - {/* 선택된 심볼 실시간 시세 */} - {selectedTicker && ( -
-
- {selectedTicker.exchange.toUpperCase()} : {selectedTicker.symbol} -
- -
- High {formatPrice(selectedTicker.high24h)} - Low {formatPrice(selectedTicker.low24h)} - - Vol{' '} - {Number(selectedTicker.volume24h).toLocaleString('ko-KR', { - maximumFractionDigits: 0, - })} - -
-
- )} - - {/* Side toggle */} - {symbol && ( - <> -
- -
- - -
-
- - {/* Type toggle */} -
- -
- - -
-
- - {/* Quantity */} -
- - setQuantity(e.target.value)} - placeholder="0.001" - required - /> - {quantity && selectedTicker && Number(quantity) > 0 && ( -

- ≈{' '} - {(Number(quantity) * Number(selectedTicker.price)).toLocaleString('ko-KR', { - maximumFractionDigits: 2, - })}{' '} - {exchange === 'upbit' ? 'KRW' : 'USDT'} -

- )} -
- - {/* Price (limit only) */} - {type === 'limit' && ( -
- - setPrice(e.target.value)} - placeholder={selectedTicker ? formatPrice(selectedTicker.price) : '0'} - required - /> -
- )} - - {mode === 'real' && exchange && !exchangeKeyId && ( -

{t('noKeyWarning', { exchange })}

- )} - - {error &&

{error}

} - - - - )} -
-
- - setShowRealConfirm(false)} - title={t('realModeConfirmTitle')} - description={t('realModeConfirmDesc')} - confirmLabel={t('realModeConfirmBtn')} - cancelLabel={t('realModeConfirmCancel')} - variant="destructive" - onConfirm={() => setMode('real')} - /> -
- ); -} diff --git a/apps/web/src/components/orders/orders-table.tsx b/apps/web/src/components/orders/orders-table.tsx deleted file mode 100644 index ccb1e53..0000000 --- a/apps/web/src/components/orders/orders-table.tsx +++ /dev/null @@ -1,361 +0,0 @@ -'use client'; - -import { useState, useMemo } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { cancelOrder } from '@/lib/api-client'; -import { useOrders } from '@/hooks/use-orders'; -import { useTranslations } from 'next-intl'; -import { ExchangeIcon, CoinIcon } from '@/components/icons'; - -type SortKey = 'createdAt' | 'exchange' | 'symbol' | 'status'; -type SortDir = 'asc' | 'desc'; -type MobileTab = 'open' | 'closed'; - -function SortIcon({ - column, - sortKey, - sortDir, -}: { - column: SortKey; - sortKey: SortKey | null; - sortDir: SortDir; -}) { - if (sortKey !== column) return ; - return sortDir === 'asc' ? ( - - ) : ( - - ); -} - -const STATUS_VARIANT: Record = - { - pending: 'warning', - placed: 'info', - filled: 'success', - partial: 'cyan', - cancelled: 'muted', - failed: 'error', - }; - -const OPEN_STATUSES = new Set(['pending', 'placed']); - -export function OrdersTable() { - const t = useTranslations('orders'); - const queryClient = useQueryClient(); - - const [sortKey, setSortKey] = useState(null); - const [sortDir, setSortDir] = useState('desc'); - const [statusFilter, setStatusFilter] = useState('all'); - const [modeFilter, setModeFilter] = useState('all'); - const [mobileTab, setMobileTab] = useState('open'); - - const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useOrders(); - - const cancelMutation = useMutation({ - mutationFn: cancelOrder, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['orders'] }); - }, - }); - - const orders = data?.pages.flatMap((p) => p.items) ?? []; - - const filteredOrders = useMemo(() => { - let result = orders; - if (statusFilter !== 'all') result = result.filter((o) => o.status === statusFilter); - if (modeFilter !== 'all') result = result.filter((o) => o.mode === modeFilter); - if (sortKey) { - result = [...result].sort((a, b) => { - const av = a[sortKey] ?? ''; - const bv = b[sortKey] ?? ''; - const cmp = String(av).localeCompare(String(bv)); - return sortDir === 'asc' ? cmp : -cmp; - }); - } - return result; - }, [orders, statusFilter, modeFilter, sortKey, sortDir]); - - const mobileOrders = useMemo(() => { - return filteredOrders.filter((o) => - mobileTab === 'open' ? OPEN_STATUSES.has(o.status) : !OPEN_STATUSES.has(o.status), - ); - }, [filteredOrders, mobileTab]); - - const toggleSort = (key: SortKey) => { - if (sortKey === key) { - setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); - } else { - setSortKey(key); - setSortDir('desc'); - } - }; - - const statusOptions = ['all', 'pending', 'filled', 'failed', 'cancelled'] as const; - const modeOptions = ['all', 'paper', 'real'] as const; - - return ( - - - {t('history')} - - - {/* Filters */} -
-
- {t('status')}: -
- {statusOptions.map((s) => ( - - ))} -
-
-
- {t('mode')}: -
- {modeOptions.map((m) => ( - - ))} -
-
-
- - {isLoading &&

{t('loading')}

} - - {/* Desktop table — hidden on mobile */} - {filteredOrders.length > 0 && ( -
- - - - - - - - - - - - - - - - - - {filteredOrders.map((order) => ( - - - - - - - - - - - - - - ))} - -
toggleSort('createdAt')} - > - {t('time')} - - toggleSort('exchange')} - > - {t('exchange')} - - toggleSort('symbol')} - > - {t('symbol')} - - {t('side')}{t('type')}{t('qty')}{t('priceLabel')}{t('filled')}{t('mode')} toggleSort('status')} - > - {t('status')} - -
- {new Date(order.createdAt).toLocaleString()} - - - - {order.exchange} - - - - - {order.symbol} - - - {order.side.toUpperCase()} - {order.type}{order.quantity} - {order.type === 'market' ? '-' : order.price || '-'} - - {order.filledQuantity !== '0' - ? `${order.filledQuantity} @ ${order.filledPrice}` - : '-'} - - - {order.mode} - - - - {order.status} - - - {['pending', 'placed'].includes(order.status) && ( - - )} -
-
- )} - - {/* Mobile card view — visible only on mobile */} -
- {/* Tabs */} -
- - -
- - {/* Cards */} - {mobileOrders.length === 0 && !isLoading && ( -

{t('noOrders')}

- )} -
- {mobileOrders.map((order) => ( -
- {/* Top row: symbol + status */} -
- - - {order.symbol} - - {order.status} -
- - {/* Middle row: side + qty + price */} -
- - {order.side === 'buy' ? t('buy') : t('sell')} - - - {t('qty')}:{' '} - {order.quantity} - - - {t('priceLabel')}:{' '} - - {order.type === 'market' ? '-' : order.price || '-'} - - -
- - {/* Bottom row: exchange + time + cancel */} -
- - - {order.exchange} - - {order.mode} - - - {new Date(order.createdAt).toLocaleString()} -
- - {['pending', 'placed'].includes(order.status) && ( - - )} -
- ))} -
-
- - {!isLoading && filteredOrders.length === 0 && ( -

{t('noOrders')}

- )} - - {hasNextPage && ( -
- -
- )} -
-
- ); -} diff --git a/apps/web/src/components/orders/quick-order-panel.tsx b/apps/web/src/components/orders/quick-order-panel.tsx deleted file mode 100644 index c323d0b..0000000 --- a/apps/web/src/components/orders/quick-order-panel.tsx +++ /dev/null @@ -1,348 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { useMutation } from '@tanstack/react-query'; -import { X } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import type { Ticker } from '@coin/types'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Dialog } from '@/components/ui/dialog'; -import { CoinIcon, ExchangeIcon } from '@/components/icons'; -import { LivePrice } from './live-price'; -import { createOrder } from '@/lib/api-client'; -import { useOrderForm } from '@/hooks/use-order-form'; -import { useExchangeKeys } from '@/hooks/use-exchange-keys'; -import { useTickers } from '@/hooks/use-tickers'; -import { formatPrice } from '@/lib/utils'; - -interface QuickOrderPanelProps { - ticker: Ticker | null; - onClose: () => void; -} - -export function QuickOrderPanel({ ticker, onClose }: QuickOrderPanelProps) { - const t = useTranslations('orders'); - const { data: keys = [] } = useExchangeKeys(); - const { tickers } = useTickers(); - - const [side, setSide] = useState<'buy' | 'sell'>('buy'); - const [type, setType] = useState<'market' | 'limit'>('market'); - const [quantity, setQuantity] = useState(''); - const [price, setPrice] = useState(''); - const [mode, setMode] = useState<'paper' | 'real'>('paper'); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(false); - const [showRealConfirm, setShowRealConfirm] = useState(false); - - // Reset state when ticker changes - useEffect(() => { - setQuantity(''); - setPrice(''); - setError(''); - setSuccess(false); - setSide('buy'); - setType('market'); - }, [ticker?.exchange, ticker?.symbol]); - - // Close on Escape key - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); - }, [onClose]); - - const exchange = ticker?.exchange ?? ''; - const symbol = ticker?.symbol ?? ''; - - const { exchangeKeyId, quoteBalance, quoteCurrency } = useOrderForm({ - exchange, - mode, - keys, - tickers, - }); - - // Get fresh live ticker from websocket feed - const liveTicker = - tickers.find((tk) => tk.exchange === exchange && tk.symbol === symbol) ?? ticker; - - const mutation = useMutation({ - mutationFn: createOrder, - onSuccess: () => { - setQuantity(''); - setPrice(''); - setError(''); - setSuccess(true); - setTimeout(() => setSuccess(false), 2000); - }, - onError: (err: Error) => setError(err.message), - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!liveTicker) return; - setError(''); - mutation.mutate({ - exchange, - symbol, - side, - type, - quantity, - ...(type === 'limit' ? { price } : {}), - mode, - ...(mode === 'real' ? { exchangeKeyId } : {}), - }); - }; - - const open = !!ticker; - - return ( - <> - {/* Backdrop */} - {open && ( -