모의투자 로직 변경

This commit is contained in:
Macbook
2026-05-31 14:05:46 +09:00
parent ead97dad5d
commit d6eedf19bb
33 changed files with 1456 additions and 330 deletions
@@ -40,8 +40,8 @@ public class LiveStrategyStartupRunner implements ApplicationRunner {
for (GcWatchlist w : watchlistRepo.findAll()) { for (GcWatchlist w : watchlistRepo.findAll()) {
Long userId = w.getUserId(); Long userId = w.getUserId();
String deviceId = w.getDeviceId(); String deviceId = w.getDeviceId();
if (userId == null && deviceId == null) continue; if (userId == null) continue;
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcAppSettings app = appSettingsService.getEntity(userId, null);
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) { if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) {
continue; continue;
} }
@@ -8,6 +8,8 @@ import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Map;
/** /**
* 백테스팅 실행 REST API. * 백테스팅 실행 REST API.
* *
@@ -40,20 +42,16 @@ public class BacktestingController {
@PostMapping("/run") @PostMapping("/run")
public ResponseEntity<BacktestResponse> run( public ResponseEntity<BacktestResponse> run(
@RequestBody BacktestRequest req, @RequestBody BacktestRequest req,
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader Map<String, String> headers) {
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) {
if (req.getBars() == null || req.getBars().isEmpty()) { if (req.getBars() == null || req.getBars().isEmpty()) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
Long userId = parseUserId(userIdHeader); long userId = TradingControllerSupport.requireRegisteredUser(headers);
// DB 저장 파라미터를 기반으로, 요청 params 로 덮어쓰기
// (요청에 params 없으면 DB 값만 사용 → 사용자가 변경한 파라미터 항상 반영)
if (userId != null || deviceId != null) {
java.util.Map<String, java.util.Map<String, Object>> dbParams = java.util.Map<String, java.util.Map<String, Object>> dbParams =
indicatorSettingsService.getAll(userId, deviceId); indicatorSettingsService.getAll(userId, null);
if (!dbParams.isEmpty()) { if (!dbParams.isEmpty()) {
java.util.Map<String, java.util.Map<String, Object>> merged = java.util.Map<String, java.util.Map<String, Object>> merged =
new java.util.HashMap<>(dbParams); new java.util.HashMap<>(dbParams);
@@ -68,14 +66,8 @@ public class BacktestingController {
} }
req.setIndicatorParams(merged); req.setIndicatorParams(merged);
} }
}
BacktestResponse response = backtestingService.run(req); BacktestResponse response = backtestingService.run(req);
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
private Long parseUserId(String header) {
if (header == null || header.isBlank()) return null;
try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; }
}
} }
@@ -6,6 +6,8 @@ import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Map;
/** /**
* 가상투자 — 실시간 조건 충족 현황 API. * 가상투자 — 실시간 조건 충족 현황 API.
* *
@@ -22,14 +24,8 @@ public class LiveConditionController {
public ResponseEntity<LiveConditionStatusDto> get( public ResponseEntity<LiveConditionStatusDto> get(
@RequestParam String market, @RequestParam String market,
@RequestParam long strategyId, @RequestParam long strategyId,
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader Map<String, String> headers) {
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) { long uid = TradingControllerSupport.requireRegisteredUser(headers);
return ResponseEntity.ok(service.evaluate(uid, null, market, strategyId));
return ResponseEntity.ok(service.evaluate(parseUserId(userIdHeader), deviceId, market, strategyId));
}
private Long parseUserId(String header) {
if (header == null || header.isBlank()) return null;
try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; }
} }
} }
@@ -8,15 +8,8 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
/**
* 실시간 전략 체크 설정 REST API.
*
* <pre>
* GET /api/strategy/settings?market=KRW-BTC → 현재 설정 조회
* PUT /api/strategy/settings → 설정 저장/갱신
* </pre>
*/
@RestController @RestController
@RequestMapping("/strategy/settings") @RequestMapping("/strategy/settings")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -27,42 +20,31 @@ public class LiveStrategySettingsController {
@GetMapping @GetMapping
public ResponseEntity<LiveStrategySettingsDto> get( public ResponseEntity<LiveStrategySettingsDto> get(
@RequestParam(value = "market", defaultValue = "KRW-BTC") String market, @RequestParam(value = "market", defaultValue = "KRW-BTC") String market,
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader Map<String, String> headers) {
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) { long uid = TradingControllerSupport.requireRegisteredUser(headers);
return ResponseEntity.ok(service.get(uid, null, market));
return ResponseEntity.ok(service.get(parseUserId(userIdHeader), deviceId, market));
} }
@PutMapping @PutMapping
public ResponseEntity<LiveStrategySettingsDto> put( public ResponseEntity<LiveStrategySettingsDto> put(
@RequestBody LiveStrategySettingsDto dto, @RequestBody LiveStrategySettingsDto dto,
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader Map<String, String> headers) {
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) { long uid = TradingControllerSupport.requireRegisteredUser(headers);
return ResponseEntity.ok(service.save(uid, null, dto));
return ResponseEntity.ok(service.save(parseUserId(userIdHeader), deviceId, dto));
} }
/** 실시간 체크 ON + 전략 지정된 종목 목록 (현재 디바이스/유저) */
@GetMapping("/active") @GetMapping("/active")
public ResponseEntity<List<LiveStrategySettingsDto>> listActive( public ResponseEntity<List<LiveStrategySettingsDto>> listActive(
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader Map<String, String> headers) {
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) { long uid = TradingControllerSupport.requireRegisteredUser(headers);
return ResponseEntity.ok(service.listActive(uid, null));
return ResponseEntity.ok(service.listActive(parseUserId(userIdHeader), deviceId));
} }
/** 관심종목 등 여러 마켓에 동일 설정 일괄 저장 */
@PutMapping("/bulk") @PutMapping("/bulk")
public ResponseEntity<List<LiveStrategySettingsDto>> putBulk( public ResponseEntity<List<LiveStrategySettingsDto>> putBulk(
@RequestBody LiveStrategyBulkRequest req, @RequestBody LiveStrategyBulkRequest req,
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader Map<String, String> headers) {
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) { long uid = TradingControllerSupport.requireRegisteredUser(headers);
return ResponseEntity.ok(service.saveBulk(uid, null, req));
return ResponseEntity.ok(service.saveBulk(parseUserId(userIdHeader), deviceId, req));
}
private Long parseUserId(String header) {
if (header == null || header.isBlank()) return null;
try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; }
} }
} }
@@ -1,6 +1,5 @@
package com.goldenchart.controller; package com.goldenchart.controller;
import com.goldenchart.dto.LiveOrderRequest;
import com.goldenchart.dto.LiveOrderRequest; import com.goldenchart.dto.LiveOrderRequest;
import com.goldenchart.dto.LiveSummaryDto; import com.goldenchart.dto.LiveSummaryDto;
import com.goldenchart.dto.LiveTradeDto; import com.goldenchart.dto.LiveTradeDto;
@@ -21,27 +20,20 @@ public class LiveTradingController {
@GetMapping("/summary") @GetMapping("/summary")
public ResponseEntity<LiveSummaryDto> summary(@RequestHeader Map<String, String> h) { public ResponseEntity<LiveSummaryDto> summary(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(liveTradingService.getSummary(userId(h), deviceId(h))); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(liveTradingService.getSummary(uid));
} }
@GetMapping("/trades") @GetMapping("/trades")
public ResponseEntity<List<LiveTradeDto>> trades(@RequestHeader Map<String, String> h) { public ResponseEntity<List<LiveTradeDto>> trades(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(liveTradingService.listTrades(userId(h), deviceId(h))); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(liveTradingService.listTrades(uid));
} }
@PostMapping("/orders") @PostMapping("/orders")
public ResponseEntity<LiveTradeDto> placeOrder(@RequestHeader Map<String, String> h, public ResponseEntity<LiveTradeDto> placeOrder(@RequestHeader Map<String, String> h,
@RequestBody LiveOrderRequest body) { @RequestBody LiveOrderRequest body) {
return ResponseEntity.ok(liveTradingService.placeManualOrder(userId(h), deviceId(h), body)); long uid = TradingControllerSupport.requireRegisteredUser(h);
} return ResponseEntity.ok(liveTradingService.placeManualOrder(uid, body));
private Long userId(Map<String, String> h) {
String v = h.get("x-user-id");
if (v == null || v.isBlank()) return null;
try { return Long.parseLong(v); } catch (NumberFormatException e) { return null; }
}
private String deviceId(Map<String, String> h) {
return h.getOrDefault("x-device-id", "anonymous");
} }
} }
@@ -19,37 +19,32 @@ public class PaperTradingController {
private final PaperTradingService paperTradingService; private final PaperTradingService paperTradingService;
private Long userId(Map<String, String> h) {
String v = h.get("x-user-id");
return v != null ? Long.parseLong(v) : null;
}
private String deviceId(Map<String, String> h) {
return h.getOrDefault("x-device-id", "anonymous");
}
@GetMapping("/summary") @GetMapping("/summary")
public ResponseEntity<PaperSummaryDto> summary(@RequestHeader Map<String, String> h) { public ResponseEntity<PaperSummaryDto> summary(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(paperTradingService.getSummary(userId(h), deviceId(h), null)); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(paperTradingService.getSummary(uid, null));
} }
@PostMapping("/summary") @PostMapping("/summary")
public ResponseEntity<PaperSummaryDto> summaryWithMarks( public ResponseEntity<PaperSummaryDto> summaryWithMarks(
@RequestHeader Map<String, String> h, @RequestHeader Map<String, String> h,
@RequestBody(required = false) Map<String, Double> markPrices) { @RequestBody(required = false) Map<String, Double> markPrices) {
return ResponseEntity.ok(paperTradingService.getSummary(userId(h), deviceId(h), markPrices)); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(paperTradingService.getSummary(uid, markPrices));
} }
@GetMapping("/allocations") @GetMapping("/allocations")
public ResponseEntity<List<PaperAllocationDto>> allocations(@RequestHeader Map<String, String> h) { public ResponseEntity<List<PaperAllocationDto>> allocations(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(paperTradingService.listAllocations(userId(h), deviceId(h), null)); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(paperTradingService.listAllocations(uid, null));
} }
@PutMapping("/allocations/bulk") @PutMapping("/allocations/bulk")
public ResponseEntity<List<PaperAllocationDto>> bulkAllocations( public ResponseEntity<List<PaperAllocationDto>> bulkAllocations(
@RequestHeader Map<String, String> h, @RequestHeader Map<String, String> h,
@RequestBody PaperAllocationBulkRequest body) { @RequestBody PaperAllocationBulkRequest body) {
return ResponseEntity.ok(paperTradingService.bulkAllocations(userId(h), deviceId(h), body)); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(paperTradingService.bulkAllocations(uid, body));
} }
@PatchMapping("/allocations/{symbol}") @PatchMapping("/allocations/{symbol}")
@@ -57,7 +52,8 @@ public class PaperTradingController {
@RequestHeader Map<String, String> h, @RequestHeader Map<String, String> h,
@PathVariable String symbol, @PathVariable String symbol,
@RequestBody PaperAllocationPatchRequest body) { @RequestBody PaperAllocationPatchRequest body) {
return ResponseEntity.ok(paperTradingService.patchAllocation(userId(h), deviceId(h), symbol, body)); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(paperTradingService.patchAllocation(uid, symbol, body));
} }
@GetMapping("/trades") @GetMapping("/trades")
@@ -70,12 +66,13 @@ public class PaperTradingController {
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(required = false) Integer size) { @RequestParam(required = false) Integer size) {
long uid = TradingControllerSupport.requireRegisteredUser(h);
if (size == null && symbol == null && side == null && source == null && from == null && to == null) { if (size == null && symbol == null && side == null && source == null && from == null && to == null) {
return ResponseEntity.ok(paperTradingService.listTradesLegacy(userId(h), deviceId(h))); return ResponseEntity.ok(paperTradingService.listTradesLegacy(uid));
} }
int pageSize = size != null ? size : 50; int pageSize = size != null ? size : 50;
return ResponseEntity.ok(paperTradingService.listTrades( return ResponseEntity.ok(paperTradingService.listTrades(
userId(h), deviceId(h), symbol, side, source, from, to, page, pageSize)); uid, symbol, side, source, from, to, page, pageSize));
} }
@GetMapping("/ledger") @GetMapping("/ledger")
@@ -85,7 +82,8 @@ public class PaperTradingController {
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) { @RequestParam(defaultValue = "50") int size) {
return ResponseEntity.ok(paperTradingService.listLedger(userId(h), deviceId(h), from, to, page, size)); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(paperTradingService.listLedger(uid, from, to, page, size));
} }
@GetMapping("/snapshots/daily") @GetMapping("/snapshots/daily")
@@ -93,30 +91,35 @@ public class PaperTradingController {
@RequestHeader Map<String, String> h, @RequestHeader Map<String, String> h,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
return ResponseEntity.ok(paperTradingService.listDailySnapshots(userId(h), deviceId(h), from, to)); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(paperTradingService.listDailySnapshots(uid, from, to));
} }
@GetMapping("/orders") @GetMapping("/orders")
public ResponseEntity<List<PaperOrderDto>> pendingOrders(@RequestHeader Map<String, String> h) { public ResponseEntity<List<PaperOrderDto>> pendingOrders(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(paperTradingService.listPendingOrders(userId(h), deviceId(h))); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(paperTradingService.listPendingOrders(uid));
} }
@PostMapping("/orders") @PostMapping("/orders")
public ResponseEntity<PaperPlaceOrderResult> placeOrder( public ResponseEntity<PaperPlaceOrderResult> placeOrder(
@RequestHeader Map<String, String> h, @RequestHeader Map<String, String> h,
@RequestBody PaperOrderRequest body) { @RequestBody PaperOrderRequest body) {
return ResponseEntity.ok(paperTradingService.placeOrder(userId(h), deviceId(h), body)); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(paperTradingService.placeOrder(uid, body));
} }
@PostMapping("/orders/{id}/cancel") @PostMapping("/orders/{id}/cancel")
public ResponseEntity<PaperOrderDto> cancelOrder( public ResponseEntity<PaperOrderDto> cancelOrder(
@RequestHeader Map<String, String> h, @RequestHeader Map<String, String> h,
@PathVariable Long id) { @PathVariable Long id) {
return ResponseEntity.ok(paperTradingService.cancelOrder(userId(h), deviceId(h), id)); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(paperTradingService.cancelOrder(uid, id));
} }
@PostMapping("/reset") @PostMapping("/reset")
public ResponseEntity<PaperSummaryDto> reset(@RequestHeader Map<String, String> h) { public ResponseEntity<PaperSummaryDto> reset(@RequestHeader Map<String, String> h) {
return ResponseEntity.ok(paperTradingService.resetAccount(userId(h), deviceId(h))); long uid = TradingControllerSupport.requireRegisteredUser(h);
return ResponseEntity.ok(paperTradingService.resetAccount(uid));
} }
} }
@@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 투자전략 CRUD REST API. * 투자전략 CRUD REST API.
@@ -29,14 +30,17 @@ public class StrategyController {
/** 전략 목록 조회 */ /** 전략 목록 조회 */
@GetMapping @GetMapping
public ResponseEntity<List<StrategyDto>> list( public ResponseEntity<List<StrategyDto>> list(
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader Map<String, String> headers) {
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) { long uid = TradingControllerSupport.requireRegisteredUser(headers);
return ResponseEntity.ok(service.list(parseUserId(userIdHeader), deviceId)); return ResponseEntity.ok(service.list(uid, null));
} }
/** 단일 전략 조회 */ /** 단일 전략 조회 */
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<StrategyDto> get(@PathVariable Long id) { public ResponseEntity<StrategyDto> get(
@PathVariable Long id,
@RequestHeader Map<String, String> headers) {
TradingControllerSupport.requireRegisteredUser(headers);
return service.findById(id) return service.findById(id)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
@@ -46,20 +50,26 @@ public class StrategyController {
@PostMapping @PostMapping
public ResponseEntity<StrategyDto> save( public ResponseEntity<StrategyDto> save(
@RequestBody StrategyDto dto, @RequestBody StrategyDto dto,
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader Map<String, String> headers) {
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) { long uid = TradingControllerSupport.requireRegisteredUser(headers);
return ResponseEntity.ok(service.save(dto, parseUserId(userIdHeader), deviceId)); return ResponseEntity.ok(service.save(dto, uid, null));
} }
/** 전략 DSL에 포함된 평가 시간봉 목록 */ /** 전략 DSL에 포함된 평가 시간봉 목록 */
@GetMapping("/{id}/timeframes") @GetMapping("/{id}/timeframes")
public ResponseEntity<List<String>> timeframes(@PathVariable Long id) { public ResponseEntity<List<String>> timeframes(
@PathVariable Long id,
@RequestHeader Map<String, String> headers) {
TradingControllerSupport.requireRegisteredUser(headers);
return ResponseEntity.ok(conditionTimeframes.collectForStrategyList(id)); return ResponseEntity.ok(conditionTimeframes.collectForStrategyList(id));
} }
/** TIMEFRAME 래핑 누락 DSL 보정 (3분봉 전략이 1분봉으로 평가되는 경우) */ /** TIMEFRAME 래핑 누락 DSL 보정 (3분봉 전략이 1분봉으로 평가되는 경우) */
@PostMapping("/{id}/repair-timeframes") @PostMapping("/{id}/repair-timeframes")
public ResponseEntity<StrategyDto> repairTimeframes(@PathVariable Long id) { public ResponseEntity<StrategyDto> repairTimeframes(
@PathVariable Long id,
@RequestHeader Map<String, String> headers) {
TradingControllerSupport.requireRegisteredUser(headers);
return service.repairTimeframeWrappers(id) return service.repairTimeframeWrappers(id)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
@@ -67,13 +77,11 @@ public class StrategyController {
/** 전략 삭제 */ /** 전략 삭제 */
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) { public ResponseEntity<Void> delete(
@PathVariable Long id,
@RequestHeader Map<String, String> headers) {
TradingControllerSupport.requireRegisteredUser(headers);
service.delete(id); service.delete(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
private Long parseUserId(String header) {
if (header == null || header.isBlank()) return null;
try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; }
}
} }
@@ -7,18 +7,8 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
/**
* 매매 시그널 이력 REST API.
*
* <pre>
* GET /api/trade-signals → 전체 이력
* GET /api/trade-signals?market= → 종목별 이력
* DELETE /api/trade-signals/{id} → 단건 삭제
* DELETE /api/trade-signals → 전체 삭제
* POST /api/trade-signals/delete-batch → ID 목록 삭제
* </pre>
*/
@RestController @RestController
@RequestMapping("/trade-signals") @RequestMapping("/trade-signals")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -29,44 +19,36 @@ public class TradeSignalController {
@GetMapping @GetMapping
public ResponseEntity<List<TradeSignalDto>> list( public ResponseEntity<List<TradeSignalDto>> list(
@RequestParam(required = false) String market, @RequestParam(required = false) String market,
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader Map<String, String> headers) {
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) { long uid = TradingControllerSupport.requireRegisteredUser(headers);
Long userId = parseUserId(userIdHeader);
List<TradeSignalDto> result = (market != null && !market.isBlank()) List<TradeSignalDto> result = (market != null && !market.isBlank())
? service.listByMarket(userId, deviceId, market) ? service.listByMarket(uid, null, market)
: service.list(userId, deviceId); : service.list(uid, null);
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<Void> deleteOne( public ResponseEntity<Void> deleteOne(
@PathVariable Long id, @PathVariable Long id,
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader Map<String, String> headers) {
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) { long uid = TradingControllerSupport.requireRegisteredUser(headers);
service.deleteOne(id, parseUserId(userIdHeader), deviceId); service.deleteOne(id, uid, null);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@DeleteMapping @DeleteMapping
public ResponseEntity<java.util.Map<String, Integer>> deleteAll( public ResponseEntity<Map<String, Integer>> deleteAll(@RequestHeader Map<String, String> headers) {
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader, long uid = TradingControllerSupport.requireRegisteredUser(headers);
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) { int deleted = service.deleteAll(uid, null);
int deleted = service.deleteAll(parseUserId(userIdHeader), deviceId); return ResponseEntity.ok(Map.of("deleted", deleted));
return ResponseEntity.ok(java.util.Map.of("deleted", deleted));
} }
@PostMapping("/delete-batch") @PostMapping("/delete-batch")
public ResponseEntity<java.util.Map<String, Integer>> deleteBatch( public ResponseEntity<Map<String, Integer>> deleteBatch(
@RequestBody java.util.List<Long> ids, @RequestBody List<Long> ids,
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader Map<String, String> headers) {
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) { long uid = TradingControllerSupport.requireRegisteredUser(headers);
int deleted = service.deleteBatch(ids, parseUserId(userIdHeader), deviceId); int deleted = service.deleteBatch(ids, uid, null);
return ResponseEntity.ok(java.util.Map.of("deleted", deleted)); return ResponseEntity.ok(Map.of("deleted", deleted));
}
private Long parseUserId(String h) {
if (h == null || h.isBlank()) return null;
try { return Long.parseLong(h); } catch (NumberFormatException e) { return null; }
} }
} }
@@ -0,0 +1,33 @@
package com.goldenchart.controller;
import com.goldenchart.trading.TradingAccess;
import java.util.Map;
/** 매매·모의·전략 API — 정식 로그인(userId) 필수 */
public final class TradingControllerSupport {
private TradingControllerSupport() {}
public static long requireRegisteredUser(Map<String, String> headers) {
String raw = headers.get("x-user-id");
if (raw == null || raw.isBlank()) {
throw new com.goldenchart.trading.TradingAccessDeniedException();
}
try {
return TradingAccess.requireUserId(Long.parseLong(raw.trim()));
} catch (NumberFormatException e) {
throw new com.goldenchart.trading.TradingAccessDeniedException();
}
}
public static Long parseUserId(Map<String, String> headers) {
String raw = headers.get("x-user-id");
if (raw == null || raw.isBlank()) return null;
try {
return Long.parseLong(raw.trim());
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -18,6 +18,9 @@ public interface GcLiveStrategySettingsRepository
/** isLiveCheck = true 인 모든 설정 (스케줄러 순회용) */ /** isLiveCheck = true 인 모든 설정 (스케줄러 순회용) */
List<GcLiveStrategySettings> findAllByIsLiveCheckTrue(); List<GcLiveStrategySettings> findAllByIsLiveCheckTrue();
/** 정식 로그인 사용자만 — 게스트(device-only) 제외 */
List<GcLiveStrategySettings> findAllByIsLiveCheckTrueAndUserIdIsNotNull();
/** 디바이스별 실시간 체크 ON 설정 */ /** 디바이스별 실시간 체크 ON 설정 */
List<GcLiveStrategySettings> findByDeviceIdAndIsLiveCheckTrue(String deviceId); List<GcLiveStrategySettings> findByDeviceIdAndIsLiveCheckTrue(String deviceId);
@@ -8,6 +8,8 @@ import java.util.Optional;
public interface GcLiveTradeRepository extends JpaRepository<GcLiveTrade, Long> { public interface GcLiveTradeRepository extends JpaRepository<GcLiveTrade, Long> {
List<GcLiveTrade> findTop100ByDeviceIdOrderByCreatedAtDesc(String deviceId); List<GcLiveTrade> findTop100ByDeviceIdOrderByCreatedAtDesc(String deviceId);
List<GcLiveTrade> findTop100ByUserIdOrderByCreatedAtDesc(Long userId);
Optional<GcLiveTrade> findTopByDeviceIdAndSymbolAndSideOrderByCreatedAtDesc( Optional<GcLiveTrade> findTopByDeviceIdAndSymbolAndSideOrderByCreatedAtDesc(
String deviceId, String symbol, String side); String deviceId, String symbol, String side);
List<GcLiveTrade> findBySymbolAndSideOrderByCreatedAtDesc(String symbol, String side); List<GcLiveTrade> findBySymbolAndSideOrderByCreatedAtDesc(String symbol, String side);
@@ -58,6 +58,7 @@ public class BarCloseStrategyEvaluationService {
long candleTimeEpoch = barEndEpoch - signalBar.getTimePeriod().getSeconds(); long candleTimeEpoch = barEndEpoch - signalBar.getTimePeriod().getSeconds();
for (GcLiveStrategySettings s : activeSettings) { for (GcLiveStrategySettings s : activeSettings) {
if (s.getUserId() == null) continue;
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), ct)) continue; if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), ct)) continue;
String closeSignal = "NONE"; String closeSignal = "NONE";
@@ -82,9 +83,9 @@ public class BarCloseStrategyEvaluationService {
market, s.getStrategyId(), null, market, s.getStrategyId(), null,
closeSignal, signalBar.getClosePrice().doubleValue(), closeSignal, signalBar.getClosePrice().doubleValue(),
candleTimeEpoch, ct, signalExecType); candleTimeEpoch, ct, signalExecType);
if (s.getDeviceId() != null) { if (s.getUserId() != null) {
orderExecutionQueue.submitSignal( orderExecutionQueue.submitSignal(
s.getDeviceId(), s.getUserId(), market, s.getUserId(), market,
s.getStrategyId(), closeSignal, s.getStrategyId(), closeSignal,
signalBar.getClosePrice().doubleValue()); signalBar.getClosePrice().doubleValue());
} }
@@ -51,22 +51,27 @@ public class DashboardService {
: watchlistRepo.findByDeviceIdOrderByDisplayOrderAsc(deviceId).size(); : watchlistRepo.findByDeviceIdOrderByDisplayOrderAsc(deviceId).size();
out.put("watchlistCount", watchlistCount); out.put("watchlistCount", watchlistCount);
int liveCheckMarkets = (int) liveSettingsRepo.findAllByIsLiveCheckTrue().stream() int liveCheckMarkets = userId != null
.filter(s -> deviceId.equals(s.getDeviceId()) || (userId != null && userId.equals(s.getUserId()))) ? (int) liveSettingsRepo.findAllByIsLiveCheckTrueAndUserIdIsNotNull().stream()
.count(); .filter(s -> userId.equals(s.getUserId()))
.count()
: 0;
out.put("liveCheckMarkets", liveCheckMarkets); out.put("liveCheckMarkets", liveCheckMarkets);
List<LiveStrategySettingsDto> active = liveStrategySettingsService.listActive(userId, deviceId); List<LiveStrategySettingsDto> active = liveStrategySettingsService.listActive(userId, deviceId);
out.put("monitoredMarkets", active.size()); out.put("monitoredMarkets", active.size());
PaperSummaryDto paper = paperTradingService.getSummary(userId, deviceId, null); if (userId != null) {
out.put("paper", paper); out.put("paper", paperTradingService.getSummary(userId, null));
out.put("live", liveTradingService.getSummary(userId));
} else {
out.put("paper", PaperSummaryDto.builder().build());
out.put("live", LiveSummaryDto.builder().build());
}
LiveSummaryDto live = liveTradingService.getSummary(userId, deviceId); List<TradeSignalDto> recentSignals = (userId != null
out.put("live", live); ? signalRepo.findByUserIdOrderByCreatedAtDesc(userId)
: signalRepo.findByDeviceIdOrderByCreatedAtDesc(deviceId != null ? deviceId : ""))
List<TradeSignalDto> recentSignals = signalRepo
.findByDeviceIdOrderByCreatedAtDesc(deviceId)
.stream().limit(8) .stream().limit(8)
.map(s -> TradeSignalDto.builder() .map(s -> TradeSignalDto.builder()
.id(s.getId()) .id(s.getId())
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.goldenchart.dto.BacktestSettingsDto; import com.goldenchart.dto.BacktestSettingsDto;
import com.goldenchart.entity.GcAppSettings; import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.repository.GcAppSettingsRepository; import com.goldenchart.repository.GcAppSettingsRepository;
import com.goldenchart.trading.TradingAccess;
import com.goldenchart.trading.TradingExecutionService; import com.goldenchart.trading.TradingExecutionService;
import com.goldenchart.trading.TradingMode; import com.goldenchart.trading.TradingMode;
import com.goldenchart.trading.pipeline.OrderRequest; import com.goldenchart.trading.pipeline.OrderRequest;
@@ -50,8 +51,9 @@ public class LiveRiskMonitorService {
if (!Boolean.TRUE.equals(app.getLiveAutoTradeEnabled())) continue; if (!Boolean.TRUE.equals(app.getLiveAutoTradeEnabled())) continue;
if (!liveTradingService.hasApiKeys(app)) continue; if (!liveTradingService.hasApiKeys(app)) continue;
if (!TradingMode.fromString(app.getTradingMode()).useLive()) continue; if (!TradingMode.fromString(app.getTradingMode()).useLive()) continue;
String deviceId = app.getDeviceId(); Long userId = app.getUserId();
if (deviceId == null || deviceId.isBlank()) continue; if (userId == null || userId <= 0) continue;
String cacheKey = TradingAccess.accountDeviceKey(userId);
try { try {
var creds = appSettingsService.resolveUpbitCredentials(app); var creds = appSettingsService.resolveUpbitCredentials(app);
if (!creds.isComplete()) continue; if (!creds.isComplete()) continue;
@@ -67,9 +69,9 @@ public class LiveRiskMonitorService {
coins.put(cur, new double[]{bal, avg}); coins.put(cur, new double[]{bal, avg});
} }
} }
accountCache.put(deviceId, coins); accountCache.put(cacheKey, coins);
} catch (Exception e) { } catch (Exception e) {
log.debug("[LiveRisk] account sync {}: {}", deviceId, e.getMessage()); log.debug("[LiveRisk] account sync userId={}: {}", userId, e.getMessage());
} }
} }
} }
@@ -79,40 +81,51 @@ public class LiveRiskMonitorService {
String currency = market.startsWith("KRW-") ? market.substring(4) : market; String currency = market.startsWith("KRW-") ? market.substring(4) : market;
for (var entry : accountCache.entrySet()) { for (var entry : accountCache.entrySet()) {
String deviceId = entry.getKey(); String cacheKey = entry.getKey();
double[] pos = entry.getValue().get(currency); double[] pos = entry.getValue().get(currency);
if (pos == null || pos[0] <= 0 || pos[1] <= 0) continue; if (pos == null || pos[0] <= 0 || pos[1] <= 0) continue;
String key = deviceId + ":" + market; String cooldownKey = cacheKey + ":" + market;
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
Long last = exitCooldown.get(key); Long last = exitCooldown.get(cooldownKey);
if (last != null && now - last < COOLDOWN_MS) continue; if (last != null && now - last < COOLDOWN_MS) continue;
GcAppSettings app = appSettingsRepo.findByDeviceId(deviceId).orElse(null); Long userId = parseUserIdFromCacheKey(cacheKey);
if (userId == null) continue;
GcAppSettings app = appSettingsRepo.findByUserId(userId).orElse(null);
if (app == null) continue; if (app == null) continue;
BacktestSettingsDto risk = backtestSettingsService.get(deviceId); BacktestSettingsDto risk = backtestSettingsService.get(cacheKey);
double avg = pos[1]; double avg = pos[1];
double pnlPct = (tradePrice - avg) / avg * 100.0; double pnlPct = (tradePrice - avg) / avg * 100.0;
if (Boolean.TRUE.equals(risk.getStopLossEnabled()) if (Boolean.TRUE.equals(risk.getStopLossEnabled())
&& risk.getStopLossPct() != null && risk.getStopLossPct() != null
&& pnlPct <= -risk.getStopLossPct().doubleValue()) { && pnlPct <= -risk.getStopLossPct().doubleValue()) {
exitCooldown.put(key, now); exitCooldown.put(cooldownKey, now);
tradingExecutionService.executeRiskExit( tradingExecutionService.executeRiskExit(
OrderRequest.stopLoss(deviceId, app.getUserId(), market, tradePrice, OrderRequest.stopLoss(cacheKey, userId, market, tradePrice,
risk.getStopLossPct().doubleValue())); risk.getStopLossPct().doubleValue()));
return; return;
} }
if (Boolean.TRUE.equals(risk.getTakeProfitEnabled()) if (Boolean.TRUE.equals(risk.getTakeProfitEnabled())
&& risk.getTakeProfitPct() != null && risk.getTakeProfitPct() != null
&& pnlPct >= risk.getTakeProfitPct().doubleValue()) { && pnlPct >= risk.getTakeProfitPct().doubleValue()) {
exitCooldown.put(key, now); exitCooldown.put(cooldownKey, now);
tradingExecutionService.executeRiskExit( tradingExecutionService.executeRiskExit(
OrderRequest.takeProfit(deviceId, app.getUserId(), market, tradePrice, OrderRequest.takeProfit(cacheKey, userId, market, tradePrice,
risk.getTakeProfitPct().doubleValue())); risk.getTakeProfitPct().doubleValue()));
return; return;
} }
} }
} }
private static Long parseUserIdFromCacheKey(String cacheKey) {
if (cacheKey == null || !cacheKey.startsWith("user:")) return null;
try {
return Long.parseLong(cacheKey.substring(5));
} catch (NumberFormatException e) {
return null;
}
}
} }
@@ -8,6 +8,7 @@ import com.goldenchart.dto.UpbitApiCredentials;
import com.goldenchart.entity.GcAppSettings; import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.entity.GcLiveTrade; import com.goldenchart.entity.GcLiveTrade;
import com.goldenchart.repository.GcLiveTradeRepository; import com.goldenchart.repository.GcLiveTradeRepository;
import com.goldenchart.trading.TradingAccess;
import com.goldenchart.trading.TradingMode; import com.goldenchart.trading.TradingMode;
import com.goldenchart.upbit.UpbitOrderApiClient; import com.goldenchart.upbit.UpbitOrderApiClient;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -36,8 +37,9 @@ public class LiveTradingService {
private final GcLiveTradeRepository tradeRepo; private final GcLiveTradeRepository tradeRepo;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public LiveSummaryDto getSummary(Long userId, String deviceId) { public LiveSummaryDto getSummary(Long userId) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); long uid = TradingAccess.requireUserId(userId);
GcAppSettings app = appSettingsService.getEntity(uid, null);
UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app); UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app);
boolean configured = creds.isComplete(); boolean configured = creds.isComplete();
double krw = 0; double krw = 0;
@@ -76,22 +78,27 @@ public class LiveTradingService {
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<LiveTradeDto> listTrades(Long userId, String deviceId) { public List<LiveTradeDto> listTrades(Long userId) {
String dev = resolveDeviceId(deviceId); long uid = TradingAccess.requireUserId(userId);
return tradeRepo.findTop100ByDeviceIdOrderByCreatedAtDesc(dev) return tradeRepo.findTop100ByUserIdOrderByCreatedAtDesc(uid)
.stream().map(this::toDto).toList(); .stream().map(this::toDto).toList();
} }
/** /**
* 전략 시그널·SL/TP 에서 호출. * 전략 시그널·SL/TP 에서 호출.
*/ */
public void tryExecuteOnSignal(String deviceId, Long userId, String market, public void tryExecuteOnSignal(Long userId, String market,
Long strategyId, String signalType, double price, Long strategyId, String signalType, double price,
String source) { String source) {
if (deviceId == null || deviceId.isBlank()) return; long uid;
try {
uid = TradingAccess.requireUserId(userId);
} catch (Exception e) {
return;
}
if (!"BUY".equals(signalType) && !"SELL".equals(signalType)) return; if (!"BUY".equals(signalType) && !"SELL".equals(signalType)) return;
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcAppSettings app = appSettingsService.getEntity(uid, null);
TradingMode mode = TradingMode.fromString(app.getTradingMode()); TradingMode mode = TradingMode.fromString(app.getTradingMode());
if (!mode.useLive()) return; if (!mode.useLive()) return;
if (!Boolean.TRUE.equals(app.getLiveAutoTradeEnabled())) return; if (!Boolean.TRUE.equals(app.getLiveAutoTradeEnabled())) return;
@@ -118,7 +125,7 @@ public class LiveTradingService {
return; return;
} }
JsonNode res = upbitApi.placeMarketBuy(access, secret, market, budget); JsonNode res = upbitApi.placeMarketBuy(access, secret, market, budget);
recordTrade(deviceId, userId, market, "BUY", source, strategyId, res, price, budget); recordTrade(uid, market, "BUY", source, strategyId, res, price, budget);
log.info("[LiveTrading] BUY {} ~{} KRW", market, (long) budget); log.info("[LiveTrading] BUY {} ~{} KRW", market, (long) budget);
} else { } else {
double vol = upbitApi.getCoinBalance(access, secret, market); double vol = upbitApi.getCoinBalance(access, secret, market);
@@ -127,7 +134,7 @@ public class LiveTradingService {
return; return;
} }
JsonNode res = upbitApi.placeMarketSell(access, secret, market, vol); JsonNode res = upbitApi.placeMarketSell(access, secret, market, vol);
recordTrade(deviceId, userId, market, "SELL", source, strategyId, res, price, vol); recordTrade(uid, market, "SELL", source, strategyId, res, price, vol);
log.info("[LiveTrading] SELL {} vol={}", market, vol); log.info("[LiveTrading] SELL {} vol={}", market, vol);
} }
} catch (UpbitOrderApiClient.UpbitApiException e) { } catch (UpbitOrderApiClient.UpbitApiException e) {
@@ -138,11 +145,12 @@ public class LiveTradingService {
/** /**
* 알림 모달·수동 주문 — 시장가 매수/매도. * 알림 모달·수동 주문 — 시장가 매수/매도.
*/ */
public LiveTradeDto placeManualOrder(Long userId, String deviceId, LiveOrderRequest req) { public LiveTradeDto placeManualOrder(Long userId, LiveOrderRequest req) {
long uid = TradingAccess.requireUserId(userId);
if (req == null || req.getMarket() == null || req.getSide() == null) { if (req == null || req.getMarket() == null || req.getSide() == null) {
throw new IllegalArgumentException("market, side required"); throw new IllegalArgumentException("market, side required");
} }
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcAppSettings app = appSettingsService.getEntity(uid, null);
UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app); UpbitApiCredentials creds = appSettingsService.resolveUpbitCredentials(app);
if (!creds.isComplete()) { if (!creds.isComplete()) {
throw new IllegalStateException("업비트 API 키가 등록되지 않았습니다."); throw new IllegalStateException("업비트 API 키가 등록되지 않았습니다.");
@@ -151,8 +159,6 @@ public class LiveTradingService {
String secret = creds.secretKey(); String secret = creds.secretKey();
String market = req.getMarket(); String market = req.getMarket();
String side = req.getSide().toUpperCase(); String side = req.getSide().toUpperCase();
String dev = resolveDeviceId(deviceId);
try { try {
if ("BUY".equals(side)) { if ("BUY".equals(side)) {
double budget = req.getKrwAmount() != null && req.getKrwAmount() > 0 double budget = req.getKrwAmount() != null && req.getKrwAmount() > 0
@@ -162,8 +168,8 @@ public class LiveTradingService {
throw new IllegalStateException("주문 가능 금액이 부족합니다."); throw new IllegalStateException("주문 가능 금액이 부족합니다.");
} }
JsonNode res = upbitApi.placeMarketBuy(access, secret, market, budget); JsonNode res = upbitApi.placeMarketBuy(access, secret, market, budget);
recordTrade(dev, userId, market, "BUY", "MANUAL", req.getStrategyId(), res, 0, budget); recordTrade(uid, market, "BUY", "MANUAL", req.getStrategyId(), res, 0, budget);
return listTrades(userId, deviceId).get(0); return listTrades(uid).get(0);
} }
if ("SELL".equals(side)) { if ("SELL".equals(side)) {
double vol = req.getQuantity() != null && req.getQuantity() > 0 double vol = req.getQuantity() != null && req.getQuantity() > 0
@@ -171,8 +177,8 @@ public class LiveTradingService {
: upbitApi.getCoinBalance(access, secret, market); : upbitApi.getCoinBalance(access, secret, market);
if (vol <= 0) throw new IllegalStateException("매도 가능 수량이 없습니다."); if (vol <= 0) throw new IllegalStateException("매도 가능 수량이 없습니다.");
JsonNode res = upbitApi.placeMarketSell(access, secret, market, vol); JsonNode res = upbitApi.placeMarketSell(access, secret, market, vol);
recordTrade(dev, userId, market, "SELL", "MANUAL", req.getStrategyId(), res, 0, vol); recordTrade(uid, market, "SELL", "MANUAL", req.getStrategyId(), res, 0, vol);
return listTrades(userId, deviceId).get(0); return listTrades(uid).get(0);
} }
throw new IllegalArgumentException("side must be BUY or SELL"); throw new IllegalArgumentException("side must be BUY or SELL");
} catch (UpbitOrderApiClient.UpbitApiException e) { } catch (UpbitOrderApiClient.UpbitApiException e) {
@@ -198,7 +204,7 @@ public class LiveTradingService {
return appSettingsService.hasUpbitApiKeys(app); return appSettingsService.hasUpbitApiKeys(app);
} }
private void recordTrade(String deviceId, Long userId, String market, String side, private void recordTrade(long userId, String market, String side,
String source, Long strategyId, JsonNode res, String source, Long strategyId, JsonNode res,
double refPrice, double amountOrVol) { double refPrice, double amountOrVol) {
String uuid = res != null ? res.path("uuid").asText(null) : null; String uuid = res != null ? res.path("uuid").asText(null) : null;
@@ -209,7 +215,7 @@ public class LiveTradingService {
: BigDecimal.valueOf(refPrice * executed).setScale(2, RM); : BigDecimal.valueOf(refPrice * executed).setScale(2, RM);
tradeRepo.save(GcLiveTrade.builder() tradeRepo.save(GcLiveTrade.builder()
.deviceId(resolveDeviceId(deviceId)) .deviceId(TradingAccess.accountDeviceKey(userId))
.userId(userId) .userId(userId)
.symbol(market) .symbol(market)
.side(side) .side(side)
@@ -239,7 +245,4 @@ public class LiveTradingService {
.build(); .build();
} }
private static String resolveDeviceId(String deviceId) {
return deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous";
}
} }
@@ -5,6 +5,7 @@ import com.goldenchart.dto.*;
import com.goldenchart.entity.*; import com.goldenchart.entity.*;
import com.goldenchart.repository.*; import com.goldenchart.repository.*;
import com.goldenchart.storage.Ta4jStorage; import com.goldenchart.storage.Ta4jStorage;
import com.goldenchart.trading.TradingAccess;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -51,9 +52,10 @@ public class PaperTradingService {
// ── 조회 ───────────────────────────────────────────────────────────────── // ── 조회 ─────────────────────────────────────────────────────────────────
@Transactional(readOnly = true) @Transactional(readOnly = true)
public PaperSummaryDto getSummary(Long userId, String deviceId, Map<String, Double> markPrices) { public PaperSummaryDto getSummary(Long userId, Map<String, Double> markPrices) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); long uid = TradingAccess.requireUserId(userId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcAppSettings app = appSettingsService.getEntity(uid, null);
GcPaperAccount account = getOrCreateAccount(uid, app);
List<GcPaperPosition> positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId()); List<GcPaperPosition> positions = positionRepo.findByAccountIdOrderBySymbolAsc(account.getId());
double stockEval = 0; double stockEval = 0;
@@ -121,38 +123,40 @@ public class PaperTradingService {
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<PaperAllocationDto> listAllocations(Long userId, String deviceId, public List<PaperAllocationDto> listAllocations(Long userId, Map<String, Double> markPrices) {
Map<String, Double> markPrices) { long uid = TradingAccess.requireUserId(userId);
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcAppSettings app = appSettingsService.getEntity(uid, null);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcPaperAccount account = getOrCreateAccount(uid, app);
PaperSummaryDto s = getSummary(userId, deviceId, markPrices); PaperSummaryDto s = getSummary(uid, markPrices);
return allocationService.listWithMetrics(account.getId(), markPrices, s.getTotalAsset()); return allocationService.listWithMetrics(account.getId(), markPrices, s.getTotalAsset());
} }
public List<PaperAllocationDto> bulkAllocations(Long userId, String deviceId, public List<PaperAllocationDto> bulkAllocations(Long userId, PaperAllocationBulkRequest req) {
PaperAllocationBulkRequest req) { long uid = TradingAccess.requireUserId(userId);
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcAppSettings app = appSettingsService.getEntity(uid, null);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcPaperAccount account = getOrCreateAccount(uid, app);
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
? account.getInitialCapitalSnapshot() ? account.getInitialCapitalSnapshot()
: app.getPaperInitialCapital(); : app.getPaperInitialCapital();
return allocationService.bulkUpsert(account.getId(), req, defaultMax); return allocationService.bulkUpsert(account.getId(), req, defaultMax);
} }
public PaperAllocationDto patchAllocation(Long userId, String deviceId, String symbol, public PaperAllocationDto patchAllocation(Long userId, String symbol,
PaperAllocationPatchRequest req) { PaperAllocationPatchRequest req) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); long uid = TradingAccess.requireUserId(userId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcAppSettings app = appSettingsService.getEntity(uid, null);
GcPaperAccount account = getOrCreateAccount(uid, app);
return allocationService.patch(account.getId(), symbol, req); return allocationService.patch(account.getId(), symbol, req);
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public PaperPageDto<PaperTradeDto> listTrades(Long userId, String deviceId, public PaperPageDto<PaperTradeDto> listTrades(Long userId,
String symbol, String side, String source, String symbol, String side, String source,
LocalDateTime from, LocalDateTime to, LocalDateTime from, LocalDateTime to,
int page, int size) { int page, int size) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); long uid = TradingAccess.requireUserId(userId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcAppSettings app = appSettingsService.getEntity(uid, null);
GcPaperAccount account = getOrCreateAccount(uid, app);
PageRequest pr = PageRequest.of(Math.max(0, page), Math.min(100, Math.max(1, size))); PageRequest pr = PageRequest.of(Math.max(0, page), Math.min(100, Math.max(1, size)));
Page<GcPaperTrade> result = tradeRepo.findFiltered( Page<GcPaperTrade> result = tradeRepo.findFiltered(
account.getId(), symbol, side, source, from, to, pr); account.getId(), symbol, side, source, from, to, pr);
@@ -165,39 +169,43 @@ public class PaperTradingService {
.build(); .build();
} }
public List<PaperTradeDto> listTradesLegacy(Long userId, String deviceId) { public List<PaperTradeDto> listTradesLegacy(Long userId) {
return listTrades(userId, deviceId, null, null, null, null, null, 0, 100).getContent(); return listTrades(userId, null, null, null, null, null, 0, 100).getContent();
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public PaperPageDto<PaperLedgerEntryDto> listLedger(Long userId, String deviceId, public PaperPageDto<PaperLedgerEntryDto> listLedger(Long userId,
LocalDateTime from, LocalDateTime to, LocalDateTime from, LocalDateTime to,
int page, int size) { int page, int size) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); long uid = TradingAccess.requireUserId(userId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcAppSettings app = appSettingsService.getEntity(uid, null);
GcPaperAccount account = getOrCreateAccount(uid, app);
return ledgerService.list(account.getId(), from, to, page, size); return ledgerService.list(account.getId(), from, to, page, size);
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<PaperDailySnapshotDto> listDailySnapshots(Long userId, String deviceId, public List<PaperDailySnapshotDto> listDailySnapshots(Long userId,
LocalDate from, LocalDate to) { LocalDate from, LocalDate to) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); long uid = TradingAccess.requireUserId(userId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcAppSettings app = appSettingsService.getEntity(uid, null);
GcPaperAccount account = getOrCreateAccount(uid, app);
return snapshotService.list(account.getId(), from, to); return snapshotService.list(account.getId(), from, to);
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<PaperOrderDto> listPendingOrders(Long userId, String deviceId) { public List<PaperOrderDto> listPendingOrders(Long userId) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, long uid = TradingAccess.requireUserId(userId);
appSettingsService.getEntity(userId, deviceId)); GcPaperAccount account = getOrCreateAccount(uid,
appSettingsService.getEntity(uid, null));
return orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(account.getId(), "PENDING") return orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(account.getId(), "PENDING")
.stream().map(this::toOrderDto).toList(); .stream().map(this::toOrderDto).toList();
} }
// ── 주문 ───────────────────────────────────────────────────────────────── // ── 주문 ─────────────────────────────────────────────────────────────────
public PaperPlaceOrderResult placeOrder(Long userId, String deviceId, PaperOrderRequest req) { public PaperPlaceOrderResult placeOrder(Long userId, PaperOrderRequest req) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); long uid = TradingAccess.requireUserId(userId);
GcAppSettings app = appSettingsService.getEntity(uid, null);
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) { if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) {
throw new IllegalStateException("모의투자가 비활성화되어 있습니다."); throw new IllegalStateException("모의투자가 비활성화되어 있습니다.");
} }
@@ -217,7 +225,7 @@ public class PaperTradingService {
String source = req.getSource() != null ? req.getSource() : "MANUAL"; String source = req.getSource() != null ? req.getSource() : "MANUAL";
String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit"; String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit";
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcPaperAccount account = getOrCreateAccount(uid, app);
double qty = req.getQuantity() != null ? req.getQuantity() : 0; double qty = req.getQuantity() != null ? req.getQuantity() : 0;
if (qty <= 0) { if (qty <= 0) {
qty = resolveAutoQuantity(account, app, req.getMarket(), side, price, qty = resolveAutoQuantity(account, app, req.getMarket(), side, price,
@@ -233,14 +241,15 @@ public class PaperTradingService {
return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build(); return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build();
} }
PaperTradeDto trade = executeTrade(userId, deviceId, app, req.getMarket(), side, orderKind, source, PaperTradeDto trade = executeTrade(uid, app, req.getMarket(), side, orderKind, source,
req.getStrategyId(), price, qty, null, false); req.getStrategyId(), price, qty, null, false);
return PaperPlaceOrderResult.builder().trade(trade).build(); return PaperPlaceOrderResult.builder().trade(trade).build();
} }
public PaperOrderDto cancelOrder(Long userId, String deviceId, Long orderId) { public PaperOrderDto cancelOrder(Long userId, Long orderId) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, long uid = TradingAccess.requireUserId(userId);
appSettingsService.getEntity(userId, deviceId)); GcPaperAccount account = getOrCreateAccount(uid,
appSettingsService.getEntity(uid, null));
GcPaperOrder order = orderRepo.findById(orderId) GcPaperOrder order = orderRepo.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다."));
if (!order.getAccountId().equals(account.getId())) { if (!order.getAccountId().equals(account.getId())) {
@@ -255,12 +264,17 @@ public class PaperTradingService {
return toOrderDto(order); return toOrderDto(order);
} }
public void tryExecuteOnSignal(String deviceId, Long userId, String market, public void tryExecuteOnSignal(Long userId, String market,
Long strategyId, String signalType, double price) { Long strategyId, String signalType, double price) {
if (deviceId == null || deviceId.isBlank()) return; long uid;
try {
uid = TradingAccess.requireUserId(userId);
} catch (Exception e) {
return;
}
if (!"BUY".equals(signalType) && !"SELL".equals(signalType)) return; if (!"BUY".equals(signalType) && !"SELL".equals(signalType)) return;
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); GcAppSettings app = appSettingsService.getEntity(uid, null);
if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) return; if (!Boolean.TRUE.equals(app.getPaperTradingEnabled())) return;
if (!Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) return; if (!Boolean.TRUE.equals(app.getPaperAutoTradeEnabled())) return;
List<GcLiveStrategySettings> liveForMarket = liveSettingsRepo.findActiveByMarket(market).stream() List<GcLiveStrategySettings> liveForMarket = liveSettingsRepo.findActiveByMarket(market).stream()
@@ -270,7 +284,7 @@ public class PaperTradingService {
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) && !marketActive) return; if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) && !marketActive) return;
try { try {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcPaperAccount account = getOrCreateAccount(uid, app);
String positionMode = liveForMarket.stream() String positionMode = liveForMarket.stream()
.map(GcLiveStrategySettings::getPositionMode) .map(GcLiveStrategySettings::getPositionMode)
.filter(m -> m != null && !m.isBlank()) .filter(m -> m != null && !m.isBlank())
@@ -299,7 +313,7 @@ public class PaperTradingService {
if (denom <= 0 || budget < minOrder) return; if (denom <= 0 || budget < minOrder) return;
double qty = budget / denom; double qty = budget / denom;
if (qty * execPrice < minOrder) return; if (qty * execPrice < minOrder) return;
executeTrade(userId, deviceId, app, market, "BUY", "market", "STRATEGY", executeTrade(uid, app, market, "BUY", "market", "STRATEGY",
strategyId, price, qty, null, true); strategyId, price, qty, null, true);
log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price); log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price);
} else { } else {
@@ -308,7 +322,7 @@ public class PaperTradingService {
if (pos == null || pos.getQuantity().doubleValue() <= 0) return; if (pos == null || pos.getQuantity().doubleValue() <= 0) return;
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue()); double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
if (avail <= 0) return; if (avail <= 0) return;
executeTrade(userId, deviceId, app, market, "SELL", "market", "STRATEGY", executeTrade(uid, app, market, "SELL", "market", "STRATEGY",
strategyId, price, avail, null, true); strategyId, price, avail, null, true);
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price); log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price);
} }
@@ -332,11 +346,13 @@ public class PaperTradingService {
GcPaperAccount account = accountRepo.findById(order.getAccountId()).orElse(null); GcPaperAccount account = accountRepo.findById(order.getAccountId()).orElse(null);
if (account == null) return; if (account == null) return;
GcAppSettings app = appSettingsService.getEntity(account.getUserId(), account.getDeviceId()); if (account.getUserId() == null) return;
long uid = account.getUserId();
GcAppSettings app = appSettingsService.getEntity(uid, null);
releaseReservation(account, order); releaseReservation(account, order);
double qty = order.getQuantity().doubleValue(); double qty = order.getQuantity().doubleValue();
executeTrade(account.getUserId(), account.getDeviceId(), app, order.getSymbol(), executeTrade(uid, app, order.getSymbol(),
order.getSide(), order.getOrderKind(), order.getSource(), order.getStrategyId(), order.getSide(), order.getOrderKind(), order.getSource(), order.getStrategyId(),
limit, qty, null, "STRATEGY".equals(order.getSource())); limit, qty, null, "STRATEGY".equals(order.getSource()));
@@ -361,9 +377,10 @@ public class PaperTradingService {
return 0; return 0;
} }
public PaperSummaryDto resetAccount(Long userId, String deviceId) { public PaperSummaryDto resetAccount(Long userId) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); long uid = TradingAccess.requireUserId(userId);
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcAppSettings app = appSettingsService.getEntity(uid, null);
GcPaperAccount account = getOrCreateAccount(uid, app);
BigDecimal initial = app.getPaperInitialCapital() != null BigDecimal initial = app.getPaperInitialCapital() != null
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000); ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000);
BigDecimal cashBefore = account.getCashBalance(); BigDecimal cashBefore = account.getCashBalance();
@@ -405,8 +422,8 @@ public class PaperTradingService {
accountRepo.save(account); accountRepo.save(account);
allocationService.seedFromPositions(account.getId(), initial); allocationService.seedFromPositions(account.getId(), initial);
log.info("[Paper] account reset device={} capital={}", deviceId, initial); log.info("[Paper] account reset userId={} capital={}", uid, initial);
return getSummary(userId, deviceId, null); return getSummary(uid, null);
} }
// ── 내부 ───────────────────────────────────────────────────────────────── // ── 내부 ─────────────────────────────────────────────────────────────────
@@ -481,11 +498,11 @@ public class PaperTradingService {
return Math.max(0, held - reserved); return Math.max(0, held - reserved);
} }
private PaperTradeDto executeTrade(Long userId, String deviceId, GcAppSettings app, private PaperTradeDto executeTrade(long userId, GcAppSettings app,
String market, String side, String orderKind, String source, String market, String side, String orderKind, String source,
Long strategyId, double inputPrice, double inputQty, Long strategyId, double inputPrice, double inputQty,
String koreanName, boolean autoTrade) { String koreanName, boolean autoTrade) {
GcPaperAccount account = getOrCreateAccount(userId, deviceId, app); GcPaperAccount account = getOrCreateAccount(userId, app);
double feeRate = pct(app.getPaperFeeRatePct(), 0.05); double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0); double slip = pct(app.getPaperSlippagePct(), 0);
double minOrder = app.getPaperMinOrderKrw() != null double minOrder = app.getPaperMinOrderKrw() != null
@@ -638,14 +655,14 @@ public class PaperTradingService {
positionRepo.save(pos); positionRepo.save(pos);
} }
private GcPaperAccount getOrCreateAccount(Long userId, String deviceId, GcAppSettings app) { private GcPaperAccount getOrCreateAccount(long userId, GcAppSettings app) {
String dev = deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous"; return accountRepo.findByUserId(userId).orElseGet(() -> {
return accountRepo.findByDeviceId(dev).orElseGet(() -> { String devKey = TradingAccess.accountDeviceKey(userId);
BigDecimal initial = app.getPaperInitialCapital() != null BigDecimal initial = app.getPaperInitialCapital() != null
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000); ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000);
GcPaperAccount a = GcPaperAccount.builder() GcPaperAccount a = GcPaperAccount.builder()
.userId(userId) .userId(userId)
.deviceId(dev) .deviceId(devKey)
.cashBalance(initial) .cashBalance(initial)
.realizedPnl(BigDecimal.ZERO) .realizedPnl(BigDecimal.ZERO)
.reservedCash(BigDecimal.ZERO) .reservedCash(BigDecimal.ZERO)
@@ -0,0 +1,26 @@
package com.goldenchart.trading;
/**
* 로그인 사용자(userId) 전용 매매·모의·전략 접근 규칙.
* 게스트(앱 입장만 한 비로그인)는 deviceId만으로 매매 기능을 사용할 수 없다.
*/
public final class TradingAccess {
private TradingAccess() {}
/** 모의 계좌 device_id 컬럼(NOT NULL UK)용 합성 키 — 실제 기기와 무관 */
public static String accountDeviceKey(long userId) {
return "user:" + userId;
}
public static long requireUserId(Long userId) {
if (userId == null || userId <= 0) {
throw new TradingAccessDeniedException();
}
return userId;
}
public static boolean isRegisteredUser(Long userId) {
return userId != null && userId > 0;
}
}
@@ -0,0 +1,22 @@
package com.goldenchart.trading;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
/**
* 게스트(비로그인) 또는 미등록 사용자가 매매·모의·전략 API에 접근할 때.
*/
@ResponseStatus(HttpStatus.FORBIDDEN)
public class TradingAccessDeniedException extends RuntimeException {
public static final String DEFAULT_MESSAGE =
"매매·모의투자·투자전략·자동매매 기능은 정식 로그인 후 이용할 수 있습니다.";
public TradingAccessDeniedException() {
super(DEFAULT_MESSAGE);
}
public TradingAccessDeniedException(String message) {
super(message);
}
}
@@ -2,6 +2,7 @@ package com.goldenchart.trading;
import com.goldenchart.entity.GcAppSettings; import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.service.AppSettingsService; import com.goldenchart.service.AppSettingsService;
import com.goldenchart.trading.TradingAccess;
import com.goldenchart.service.LiveTradingService; import com.goldenchart.service.LiveTradingService;
import com.goldenchart.service.PaperTradingService; import com.goldenchart.service.PaperTradingService;
import com.goldenchart.trading.pipeline.OrderRequest; import com.goldenchart.trading.pipeline.OrderRequest;
@@ -21,32 +22,34 @@ public class TradingExecutionService {
private final PaperTradingService paperTradingService; private final PaperTradingService paperTradingService;
private final LiveTradingService liveTradingService; private final LiveTradingService liveTradingService;
public void executeSignal(String deviceId, Long userId, String market, public void executeSignal(Long userId, String market,
Long strategyId, String side, double price) { Long strategyId, String side, double price) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); if (!TradingAccess.isRegisteredUser(userId)) return;
GcAppSettings app = appSettingsService.getEntity(userId, null);
TradingMode mode = TradingMode.fromString(app.getTradingMode()); TradingMode mode = TradingMode.fromString(app.getTradingMode());
if (mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled())) { if (mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled())) {
paperTradingService.tryExecuteOnSignal(deviceId, userId, market, strategyId, side, price); paperTradingService.tryExecuteOnSignal(userId, market, strategyId, side, price);
} }
if (mode.useLive()) { if (mode.useLive()) {
liveTradingService.tryExecuteOnSignal( liveTradingService.tryExecuteOnSignal(
deviceId, userId, market, strategyId, side, price, "STRATEGY"); userId, market, strategyId, side, price, "STRATEGY");
} }
} }
public void executeRiskExit(OrderRequest req) { public void executeRiskExit(OrderRequest req) {
GcAppSettings app = appSettingsService.getEntity(req.userId(), req.deviceId()); if (!TradingAccess.isRegisteredUser(req.userId())) return;
GcAppSettings app = appSettingsService.getEntity(req.userId(), null);
TradingMode mode = TradingMode.fromString(app.getTradingMode()); TradingMode mode = TradingMode.fromString(app.getTradingMode());
String source = req.kind() == OrderRequest.OrderKind.STOP_LOSS ? "STOP_LOSS" : "TAKE_PROFIT"; String source = req.kind() == OrderRequest.OrderKind.STOP_LOSS ? "STOP_LOSS" : "TAKE_PROFIT";
if (mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled())) { if (mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled())) {
paperTradingService.tryExecuteOnSignal( paperTradingService.tryExecuteOnSignal(
req.deviceId(), req.userId(), req.market(), null, "SELL", req.price()); req.userId(), req.market(), null, "SELL", req.price());
} }
if (mode.useLive()) { if (mode.useLive()) {
liveTradingService.tryExecuteOnSignal( liveTradingService.tryExecuteOnSignal(
req.deviceId(), req.userId(), req.market(), null, "SELL", req.price(), source); req.userId(), req.market(), null, "SELL", req.price(), source);
} }
} }
} }
@@ -1,5 +1,6 @@
package com.goldenchart.trading.pipeline; package com.goldenchart.trading.pipeline;
import com.goldenchart.trading.TradingAccess;
import com.goldenchart.trading.TradingExecutionService; import com.goldenchart.trading.TradingExecutionService;
import com.google.common.util.concurrent.RateLimiter; import com.google.common.util.concurrent.RateLimiter;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@@ -64,9 +65,11 @@ public class OrderExecutionQueue {
} }
} }
public void submitSignal(String deviceId, Long userId, String market, public void submitSignal(Long userId, String market,
Long strategyId, String signal, double price) { Long strategyId, String signal, double price) {
submit(OrderRequest.strategySignal(deviceId, userId, market, strategyId, signal, price)); if (!TradingAccess.isRegisteredUser(userId)) return;
submit(OrderRequest.strategySignal(
TradingAccess.accountDeviceKey(userId), userId, market, strategyId, signal, price));
} }
private void runLoop() { private void runLoop() {
@@ -88,12 +91,12 @@ public class OrderExecutionQueue {
} }
private void executeNow(OrderRequest req) { private void executeNow(OrderRequest req) {
if (req.deviceId() == null || req.deviceId().isBlank()) return; if (!TradingAccess.isRegisteredUser(req.userId())) return;
if (!"BUY".equals(req.side()) && !"SELL".equals(req.side())) return; if (!"BUY".equals(req.side()) && !"SELL".equals(req.side())) return;
try { try {
if (req.kind() == OrderRequest.OrderKind.STRATEGY_SIGNAL) { if (req.kind() == OrderRequest.OrderKind.STRATEGY_SIGNAL) {
tradingExecutionService.executeSignal( tradingExecutionService.executeSignal(
req.deviceId(), req.userId(), req.market(), req.userId(), req.market(),
req.strategyId(), req.side(), req.price()); req.strategyId(), req.side(), req.price());
} else { } else {
tradingExecutionService.executeRiskExit(req); tradingExecutionService.executeRiskExit(req);
@@ -8,6 +8,7 @@ import com.goldenchart.repository.GcPaperAccountRepository;
import com.goldenchart.repository.GcPaperPositionRepository; import com.goldenchart.repository.GcPaperPositionRepository;
import com.goldenchart.service.AppSettingsService; import com.goldenchart.service.AppSettingsService;
import com.goldenchart.service.BacktestSettingsService; import com.goldenchart.service.BacktestSettingsService;
import com.goldenchart.trading.TradingAccess;
import com.goldenchart.trading.TradingExecutionService; import com.goldenchart.trading.TradingExecutionService;
import com.goldenchart.trading.TradingMode; import com.goldenchart.trading.TradingMode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -53,13 +54,12 @@ public class RealtimePositionMonitor {
GcPaperAccount account = accountRepo.findById(pos.getAccountId()).orElse(null); GcPaperAccount account = accountRepo.findById(pos.getAccountId()).orElse(null);
if (account == null) continue; if (account == null) continue;
String deviceId = account.getDeviceId();
Long userId = account.getUserId(); Long userId = account.getUserId();
if (deviceId == null || deviceId.isBlank()) continue; if (userId == null || userId <= 0) continue;
GcAppSettings app; GcAppSettings app;
try { try {
app = appSettingsService.getEntity(userId, deviceId); app = appSettingsService.getEntity(userId, null);
} catch (Exception e) { } catch (Exception e) {
continue; continue;
} }
@@ -75,7 +75,7 @@ public class RealtimePositionMonitor {
double avg = pos.getAvgPrice().doubleValue(); double avg = pos.getAvgPrice().doubleValue();
if (avg <= 0) continue; if (avg <= 0) continue;
BacktestSettingsDto risk = backtestSettingsService.get(deviceId); BacktestSettingsDto risk = backtestSettingsService.get(TradingAccess.accountDeviceKey(userId));
double pnlPct = (tradePrice - avg) / avg * 100.0; double pnlPct = (tradePrice - avg) / avg * 100.0;
if (Boolean.TRUE.equals(risk.getStopLossEnabled()) if (Boolean.TRUE.equals(risk.getStopLossEnabled())
@@ -84,7 +84,7 @@ public class RealtimePositionMonitor {
lastExitMs.put(cooldownKey, now); lastExitMs.put(cooldownKey, now);
double sl = risk.getStopLossPct().doubleValue(); double sl = risk.getStopLossPct().doubleValue();
tradingExecutionService.executeRiskExit( tradingExecutionService.executeRiskExit(
OrderRequest.stopLoss(deviceId, userId, market, tradePrice, sl)); OrderRequest.stopLoss(TradingAccess.accountDeviceKey(userId), userId, market, tradePrice, sl));
log.info("[SL] {} @ {} pnl={}% (limit -{}%)", market, tradePrice, log.info("[SL] {} @ {} pnl={}% (limit -{}%)", market, tradePrice,
String.format("%.2f", pnlPct), sl); String.format("%.2f", pnlPct), sl);
return; return;
@@ -96,7 +96,7 @@ public class RealtimePositionMonitor {
lastExitMs.put(cooldownKey, now); lastExitMs.put(cooldownKey, now);
double tp = risk.getTakeProfitPct().doubleValue(); double tp = risk.getTakeProfitPct().doubleValue();
tradingExecutionService.executeRiskExit( tradingExecutionService.executeRiskExit(
OrderRequest.takeProfit(deviceId, userId, market, tradePrice, tp)); OrderRequest.takeProfit(TradingAccess.accountDeviceKey(userId), userId, market, tradePrice, tp));
log.info("[TP] {} @ {} pnl={}% (target +{}%)", market, tradePrice, log.info("[TP] {} @ {} pnl={}% (target +{}%)", market, tradePrice,
String.format("%.2f", pnlPct), tp); String.format("%.2f", pnlPct), tp);
return; return;
@@ -48,7 +48,8 @@ public class LiveStrategyScheduler {
*/ */
@Scheduled(fixedDelay = 3000) @Scheduled(fixedDelay = 3000)
public void tick() { public void tick() {
List<GcLiveStrategySettings> activeSettings = settingsRepo.findAllByIsLiveCheckTrue(); List<GcLiveStrategySettings> activeSettings =
settingsRepo.findAllByIsLiveCheckTrueAndUserIdIsNotNull();
if (activeSettings.isEmpty()) return; if (activeSettings.isEmpty()) return;
for (GcLiveStrategySettings s : activeSettings) { for (GcLiveStrategySettings s : activeSettings) {
@@ -104,9 +105,9 @@ public class LiveStrategyScheduler {
lastBar.getClosePrice().doubleValue(), lastBar.getClosePrice().doubleValue(),
barStartEpoch, candleType, "REALTIME_TICK" barStartEpoch, candleType, "REALTIME_TICK"
); );
if (s.getDeviceId() != null) { if (s.getUserId() != null) {
orderExecutionQueue.submitSignal( orderExecutionQueue.submitSignal(
s.getDeviceId(), s.getUserId(), market, s.getUserId(), market,
s.getStrategyId(), signal, s.getStrategyId(), signal,
lastBar.getClosePrice().doubleValue()); lastBar.getClosePrice().doubleValue());
} }
@@ -0,0 +1,18 @@
-- V54: 매매 데이터 초기화 + 비회원(device-only) 실시간 전략 체크 OFF
-- 로그인 userId 단일 계좌·자동매매 전환 (기존 device 계좌 데이터 폐기)
UPDATE gc_live_strategy_settings
SET is_live_check = 0
WHERE user_id IS NULL;
DELETE FROM gc_paper_order;
DELETE FROM gc_paper_trade;
DELETE FROM gc_paper_cash_ledger;
DELETE FROM gc_paper_daily_snapshot;
DELETE FROM gc_paper_reset_log;
DELETE FROM gc_paper_symbol_allocation;
DELETE FROM gc_paper_position;
DELETE FROM gc_paper_account;
DELETE FROM gc_trade_signal;
DELETE FROM gc_live_trade;
+38
View File
@@ -12974,3 +12974,41 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
.app-download-ios-note { .app-download-ios-note {
margin-top: 12px; font-size: 11px; color: var(--text3); line-height: 1.45; margin-top: 12px; font-size: 11px; color: var(--text3); line-height: 1.45;
} }
/* ── 정식 로그인 유도 (게스트·매매 기능) ─────────────────────────────────── */
.formal-login-gate {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 48px);
padding: 32px 24px;
text-align: center;
background: var(--bg);
}
.formal-login-gate__title {
margin: 0 0 12px;
font-size: 20px;
font-weight: 700;
color: var(--text);
}
.formal-login-gate__desc {
margin: 0 0 24px;
max-width: 420px;
font-size: 14px;
line-height: 1.55;
color: var(--text2);
}
.formal-login-gate__btn {
padding: 12px 28px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.formal-login-gate__btn:hover {
filter: brightness(1.06);
}
+61 -13
View File
@@ -86,6 +86,13 @@ import LiveStrategyPanel from './components/LiveStrategyPanel';
import { TradeNotificationProvider } from './contexts/TradeNotificationContext'; import { TradeNotificationProvider } from './contexts/TradeNotificationContext';
import { VerificationIssueNotificationProvider } from './contexts/VerificationIssueNotificationContext'; import { VerificationIssueNotificationProvider } from './contexts/VerificationIssueNotificationContext';
import { LiveSignalNotifier, type LiveSignalNotifierHandle } from './components/LiveSignalNotifier'; import { LiveSignalNotifier, type LiveSignalNotifierHandle } from './components/LiveSignalNotifier';
import { FormalLoginGate } from './components/FormalLoginGate';
import {
isFormalLogin,
isTradingMenuPage,
isTradingSettingsCategory,
FORMAL_LOGIN_REQUIRED_MSG,
} from './utils/tradingAccess';
import { NotifyTopMenuBar, ChartToolbarNotify } from './components/NotifyUiBindings'; import { NotifyTopMenuBar, ChartToolbarNotify } from './components/NotifyUiBindings';
import { AppNotificationLayer } from './components/AppNotificationLayer'; import { AppNotificationLayer } from './components/AppNotificationLayer';
import TradeNotificationListPage from './components/TradeNotificationListPage'; import TradeNotificationListPage from './components/TradeNotificationListPage';
@@ -163,7 +170,13 @@ function App() {
const [guestMode, setGuestMode] = useState(() => const [guestMode, setGuestMode] = useState(() =>
isAppEntered() && !getAuthSession(), isAppEntered() && !getAuthSession(),
); );
const formalLogin = isFormalLogin(authUser, guestMode);
const [loginOpen, setLoginOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false);
const requireFormalLogin = useCallback(() => {
window.alert(FORMAL_LOGIN_REQUIRED_MSG);
setLoginOpen(true);
}, []);
const [appDownloadOpen, setAppDownloadOpen] = useState(false); const [appDownloadOpen, setAppDownloadOpen] = useState(false);
// 전역 지표 파라미터 + 시각 설정 DB 동기화 // 전역 지표 파라미터 + 시각 설정 DB 동기화
@@ -593,13 +606,17 @@ function App() {
}, []); }, []);
const guardedSetMenuPage = useCallback((page: MenuPage) => { const guardedSetMenuPage = useCallback((page: MenuPage) => {
if (!formalLogin && isTradingMenuPage(page)) {
requireFormalLogin();
return;
}
if (page === 'notifications' && !canMenu('notifications')) return; if (page === 'notifications' && !canMenu('notifications')) return;
if (page !== 'notifications' && !canMenu(page)) { if (page !== 'notifications' && !canMenu(page)) {
setMenuPage(firstAllowedTopMenu(menuPermissions)); setMenuPage(firstAllowedTopMenu(menuPermissions));
return; return;
} }
setMenuPage(page); setMenuPage(page);
}, [canMenu, menuPermissions]); }, [canMenu, menuPermissions, formalLogin, requireFormalLogin]);
useEffect(() => { useEffect(() => {
if (!menuPermsLoaded) return; if (!menuPermsLoaded) return;
@@ -814,12 +831,13 @@ function App() {
/** STOMP 구독 대상 — 가상투자 실행 중이면 투자대상 종목, 아니면 관심종목 */ /** STOMP 구독 대상 — 가상투자 실행 중이면 투자대상 종목, 아니면 관심종목 */
const monitoredMarkets = useMemo(() => { const monitoredMarkets = useMemo(() => {
if (!formalLogin) return [];
if (isVirtualActive) return virtualMonitoredMarkets; if (isVirtualActive) return virtualMonitoredMarkets;
if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return []; if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return [];
const set = new Set(watchlist); const set = new Set(watchlist);
if (symbol) set.add(symbol); if (symbol) set.add(symbol);
return [...set]; return [...set];
}, [isVirtualActive, virtualMonitoredMarkets, watchlist, symbol, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId]); }, [formalLogin, isVirtualActive, virtualMonitoredMarkets, watchlist, symbol, appDefaults.liveStrategyCheck, appDefaults.liveStrategyId]);
const [marketSubscriptions, setMarketSubscriptions] = useState<{ market: string; candleType: string }[]>([]); const [marketSubscriptions, setMarketSubscriptions] = useState<{ market: string; candleType: string }[]>([]);
@@ -1693,7 +1711,7 @@ function App() {
markets={monitoredMarkets} markets={monitoredMarkets}
marketSubscriptions={liveStompSubscriptions} marketSubscriptions={liveStompSubscriptions}
activeMarket={symbol} activeMarket={symbol}
enabled={monitoredMarkets.length > 0} enabled={formalLogin && monitoredMarkets.length > 0}
popupEnabled={appDefaults.tradeAlertPopup ?? true} popupEnabled={appDefaults.tradeAlertPopup ?? true}
chartMarkersActive={menuPage === 'chart'} chartMarkersActive={menuPage === 'chart'}
onMarkersChange={markers => { onMarkersChange={markers => {
@@ -1737,19 +1755,26 @@ function App() {
)} )}
{menuPage === 'strategy' && ( {menuPage === 'strategy' && (
<StrategyPage theme={theme} activeIndicators={indicators} /> formalLogin
? <StrategyPage theme={theme} activeIndicators={indicators} />
: <FormalLoginGate onLogin={requireFormalLogin} />
)} )}
{menuPage === 'strategy-editor' && ( {menuPage === 'strategy-editor' && (
<StrategyEditorPage theme={theme} /> formalLogin
? <StrategyEditorPage theme={theme} />
: <FormalLoginGate onLogin={requireFormalLogin} />
)} )}
{/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */} {/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */}
{menuPage === 'backtest' && ( {menuPage === 'backtest' && (
<BacktestHistoryPage theme={theme} /> formalLogin
? <BacktestHistoryPage theme={theme} />
: <FormalLoginGate onLogin={requireFormalLogin} />
)} )}
{menuPage === 'paper' && ( {menuPage === 'paper' && (
formalLogin ? (
<PaperTradingPage <PaperTradingPage
theme={theme} theme={theme}
tickers={marketTickers} tickers={marketTickers}
@@ -1759,6 +1784,7 @@ function App() {
paperAutoTradeEnabled={paperAutoTradeEnabled} paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={handlePaperOrderFilled} onPaperOrderFilled={handlePaperOrderFilled}
onOpenSettings={() => { onOpenSettings={() => {
if (!formalLogin) { requireFormalLogin(); return; }
setSettingsInitialCategory('paper'); setSettingsInitialCategory('paper');
setMenuPage('settings'); setMenuPage('settings');
}} }}
@@ -1767,9 +1793,13 @@ function App() {
setMenuPage('chart'); setMenuPage('chart');
}} }}
/> />
) : (
<FormalLoginGate onLogin={requireFormalLogin} title="투자관리" />
)
)} )}
{menuPage === 'virtual' && ( {menuPage === 'virtual' && (
formalLogin ? (
<VirtualTradingPage <VirtualTradingPage
theme={theme} theme={theme}
tickers={marketTickers} tickers={marketTickers}
@@ -1780,6 +1810,9 @@ function App() {
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })} onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
onPaperOrderFilled={handlePaperOrderFilled} onPaperOrderFilled={handlePaperOrderFilled}
/> />
) : (
<FormalLoginGate onLogin={requireFormalLogin} title="가상매매" />
)
)} )}
{menuPage === 'trend-search' && ( {menuPage === 'trend-search' && (
@@ -1802,6 +1835,7 @@ function App() {
{/* ── 설정 화면 ──────────────────────────────────────────────────── */} {/* ── 설정 화면 ──────────────────────────────────────────────────── */}
{menuPage === 'notifications' && canMenu('notifications') && ( {menuPage === 'notifications' && canMenu('notifications') && (
formalLogin ? (
<TradeNotificationListPage <TradeNotificationListPage
theme={theme} theme={theme}
tickers={marketTickers} tickers={marketTickers}
@@ -1814,10 +1848,15 @@ function App() {
setMenuPage('chart'); setMenuPage('chart');
}} }}
/> />
) : (
<FormalLoginGate onLogin={requireFormalLogin} title="알림목록" />
)
)} )}
{menuPage === 'settings' && canMenu('settings') && ( {menuPage === 'settings' && canMenu('settings') && (
<SettingsPage <SettingsPage
isFormalLogin={formalLogin}
onRequireFormalLogin={requireFormalLogin}
theme={theme} theme={theme}
menuPermissions={menuPermissions} menuPermissions={menuPermissions}
magnetMode={magnetMode} magnetMode={magnetMode}
@@ -2034,7 +2073,7 @@ function App() {
backtestActive={btMatchesChart} backtestActive={btMatchesChart}
backtestRunning={btRunning} backtestRunning={btRunning}
backtestRunningId={undefined} backtestRunningId={undefined}
onRunBacktest={menuPage === 'chart' && layoutDef.count === 1 onRunBacktest={menuPage === 'chart' && layoutDef.count === 1 && formalLogin
? async (opts) => { ? async (opts) => {
if (opts.strategyName) setBtStrategyName(opts.strategyName); if (opts.strategyName) setBtStrategyName(opts.strategyName);
if (useUpbit && (isLoading || bars.length === 0)) return; if (useUpbit && (isLoading || bars.length === 0)) return;
@@ -2066,16 +2105,25 @@ function App() {
if (appDefaults.btAutoPopup) setShowBtResult(true); if (appDefaults.btAutoPopup) setShowBtResult(true);
} }
: undefined} : undefined}
onOpenBtSettings={() => setShowBtSettings(true)} onOpenBtSettings={() => {
onOpenBtResults={() => setShowBtResult(true)} if (!formalLogin) { requireFormalLogin(); return; }
setShowBtSettings(true);
}}
onOpenBtResults={() => {
if (!formalLogin) { requireFormalLogin(); return; }
setShowBtResult(true);
}}
hasBtResult={btMatchesChart} hasBtResult={btMatchesChart}
onClearBtMarkers={() => { onClearBtMarkers={() => {
btMarkerPayloadRef.current = null; btMarkerPayloadRef.current = null;
btClear(); btClear();
managerRef.current?.clearBacktestMarkers(); managerRef.current?.clearBacktestMarkers();
}} }}
onToggleLiveStrategy={() => setShowLivePanel(v => !v)} onToggleLiveStrategy={() => {
liveStrategyActive={showLivePanel || monitoredMarkets.length > 0} if (!formalLogin) { requireFormalLogin(); return; }
setShowLivePanel(v => !v);
}}
liveStrategyActive={formalLogin && (showLivePanel || monitoredMarkets.length > 0)}
showChartQuote={useUpbit} showChartQuote={useUpbit}
chartLiveQuote={liveQuote} chartLiveQuote={liveQuote}
chartQuoteLoading={isLoading} chartQuoteLoading={isLoading}
@@ -2584,8 +2632,8 @@ function App() {
onMarketSelect={handleTradeMarketSelect} onMarketSelect={handleTradeMarketSelect}
availableKrw={paperCashBalance} availableKrw={paperCashBalance}
availableCoinQty={paperCoinQty} availableCoinQty={paperCoinQty}
paperTradingEnabled={paperTradingEnabled} paperTradingEnabled={formalLogin && paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled} paperAutoTradeEnabled={formalLogin && paperAutoTradeEnabled}
onPaperOrderFilled={() => { onPaperOrderFilled={() => {
refreshPaperAccount(symbol); refreshPaperAccount(symbol);
handlePaperOrderFilled(); handlePaperOrderFilled();
@@ -0,0 +1,23 @@
import React from 'react';
import { FORMAL_LOGIN_REQUIRED_MSG } from '../utils/tradingAccess';
interface Props {
onLogin: () => void;
title?: string;
}
/** 게스트·비로그인 — 매매 관련 화면 대체 */
export const FormalLoginGate: React.FC<Props> = ({
onLogin,
title = '정식 로그인이 필요합니다',
}) => (
<div className="formal-login-gate">
<h2 className="formal-login-gate__title">{title}</h2>
<p className="formal-login-gate__desc">{FORMAL_LOGIN_REQUIRED_MSG}</p>
<button type="button" className="formal-login-gate__btn" onClick={onLogin}>
</button>
</div>
);
export default FormalLoginGate;
+18 -2
View File
@@ -33,6 +33,7 @@ import {
import TradeAlertPopupPreview from './TradeAlertPopupPreview'; import TradeAlertPopupPreview from './TradeAlertPopupPreview';
import TrendSearchSettingsPanel from './trendSearch/TrendSearchSettingsPanel'; import TrendSearchSettingsPanel from './trendSearch/TrendSearchSettingsPanel';
import type { TrendSearchAppSettings } from '../utils/trendSearchAppSettings'; import type { TrendSearchAppSettings } from '../utils/trendSearchAppSettings';
import { isTradingSettingsCategory } from '../utils/tradingAccess';
import { import {
CHART_LEGEND_SETTING_ITEMS, CHART_LEGEND_SETTING_ITEMS,
type ChartLegendVisibility, type ChartLegendVisibility,
@@ -168,6 +169,9 @@ interface SettingsPageProps {
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void; onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
/** 설정 화면 진입 시 선택할 카테고리 (예: 모의투자 화면에서 링크) */ /** 설정 화면 진입 시 선택할 카테고리 (예: 모의투자 화면에서 링크) */
initialCategory?: SettingsCategoryId; initialCategory?: SettingsCategoryId;
/** 정식 로그인 여부 (게스트는 매매·전략 설정 탭 차단) */
isFormalLogin?: boolean;
onRequireFormalLogin?: () => void;
} }
// ── 카테고리 정의 ──────────────────────────────────────────────────────────── // ── 카테고리 정의 ────────────────────────────────────────────────────────────
@@ -1743,6 +1747,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
trendSearchSettings, trendSearchSettings,
onTrendSearchSettingsChange, onTrendSearchSettingsChange,
initialCategory, initialCategory,
isFormalLogin = true,
onRequireFormalLogin,
}) => { }) => {
const categories = filterCategories(menuPermissions); const categories = filterCategories(menuPermissions);
const [active, setActive] = useState<CategoryId>(initialCategory ?? 'general'); const [active, setActive] = useState<CategoryId>(initialCategory ?? 'general');
@@ -1757,10 +1763,14 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
useEffect(() => { useEffect(() => {
if (!initialCategory) return; if (!initialCategory) return;
if (!isFormalLogin && isTradingSettingsCategory(initialCategory)) {
onRequireFormalLogin?.();
return;
}
if (categories.some(c => c.id === initialCategory)) { if (categories.some(c => c.id === initialCategory)) {
setActive(initialCategory); setActive(initialCategory);
} }
}, [initialCategory, categories]); }, [initialCategory, categories, isFormalLogin, onRequireFormalLogin]);
useEffect(() => { useEffect(() => {
if (active !== 'indicators') setIndicatorSaveUi(null); if (active !== 'indicators') setIndicatorSaveUi(null);
@@ -1948,7 +1958,13 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
<button <button
key={cat.id} key={cat.id}
className={`stg-sidebar-item ${active === cat.id ? 'stg-sidebar-item--active' : ''}`} className={`stg-sidebar-item ${active === cat.id ? 'stg-sidebar-item--active' : ''}`}
onClick={() => setActive(cat.id)} onClick={() => {
if (!isFormalLogin && isTradingSettingsCategory(cat.id)) {
onRequireFormalLogin?.();
return;
}
setActive(cat.id);
}}
> >
<span className="stg-sidebar-icon">{cat.icon}</span> <span className="stg-sidebar-icon">{cat.icon}</span>
<span className="stg-sidebar-label" style={{ fontSize: 13.5, fontWeight: 'inherit' }}>{cat.label}</span> <span className="stg-sidebar-label" style={{ fontSize: 13.5, fontWeight: 'inherit' }}>{cat.label}</span>
+2 -2
View File
@@ -3,6 +3,7 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { loginUser, type LoginResponse } from '../utils/backendApi'; import { loginUser, type LoginResponse } from '../utils/backendApi';
import SplashGradientBackdrop from './splash/SplashGradientBackdrop';
import '../styles/splashScreen.css'; import '../styles/splashScreen.css';
const DEFAULT_USERNAME = 'admin'; const DEFAULT_USERNAME = 'admin';
@@ -36,8 +37,7 @@ const SplashScreen: React.FC<Props> = ({ onLoginSuccess, onGuest }) => {
return ( return (
<div className="splash"> <div className="splash">
<div className="splash-photo-bg" aria-hidden /> <SplashGradientBackdrop />
<div className="splash-photo-overlay" aria-hidden />
<div className="splash-center"> <div className="splash-center">
<div className="splash-glass"> <div className="splash-glass">
@@ -0,0 +1,39 @@
/**
* 로그인 스플래시 — 중앙→우상단 대각선으로 흐릿한 캔들·호가 실루엣 + 메시 그라데이션
*/
import React from 'react';
import SplashChartBackground from './SplashChartBackground';
import SplashOrderbookBackground from './SplashOrderbookBackground';
const SplashGradientBackdrop: React.FC = () => (
<div className="splash-backdrop" aria-hidden>
<div className="splash-backdrop__base" />
<div className="splash-backdrop__mesh" />
<div className="splash-backdrop__orbs">
<span className="splash-backdrop__orb splash-backdrop__orb--gold" />
<span className="splash-backdrop__orb splash-backdrop__orb--teal" />
<span className="splash-backdrop__orb splash-backdrop__orb--indigo" />
</div>
{/* 캔들 + 호가 — 화면 중앙에서 우측 상단으로 퍼지는 그라데이션 마스크 */}
<div className="splash-backdrop__market-flow">
<div className="splash-backdrop__flow-glow" />
<div className="splash-backdrop__flow-inner">
<div className="splash-backdrop__chart-layer">
<SplashChartBackground />
</div>
<div className="splash-backdrop__orderbook-layer">
<SplashOrderbookBackground />
</div>
</div>
</div>
<div className="splash-backdrop__grid" />
<div className="splash-backdrop__grain" />
<div className="splash-backdrop__vignette" />
<div className="splash-backdrop__spotlight" />
</div>
);
export default SplashGradientBackdrop;
@@ -0,0 +1,69 @@
import React, { useMemo } from 'react';
const W = 420;
const H = 560;
const ROWS = 14;
const MID = H / 2;
/** 호가창 실루엣 — 로그인 배경용 */
export default function SplashOrderbookBackground() {
const rows = useMemo(() => {
const out: { y: number; askW: number; bidW: number; askPx: number; bidPx: number }[] = [];
for (let i = 0; i < ROWS; i++) {
const t = i / (ROWS - 1);
const y = 36 + t * (H - 72);
const dist = Math.abs(y - MID) / MID;
const spread = 0.35 + dist * 0.65;
const askW = 40 + (1 - dist) * 90 + Math.sin(i * 1.1) * 12;
const bidW = 38 + (1 - dist) * 85 + Math.cos(i * 0.9) * 10;
const base = 118000 + (0.5 - t) * 4200;
out.push({
y,
askW,
bidW,
askPx: base + spread * 120,
bidPx: base - spread * 115,
});
}
return out;
}, []);
return (
<svg
className="splash-orderbook-bg"
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="xMidYMid meet"
aria-hidden
>
<rect width={W} height={H} fill="transparent" />
<line x1={W / 2} y1={28} x2={W / 2} y2={H - 28} className="splash-ob-mid" />
{rows.map((r, i) => (
<g key={i}>
<rect
x={W / 2 - r.askW}
y={r.y - 9}
width={r.askW}
height={18}
className="splash-ob-ask-bar"
rx="2"
/>
<rect
x={W / 2}
y={r.y - 9}
width={r.bidW}
height={18}
className="splash-ob-bid-bar"
rx="2"
/>
<text x={W / 2 - 10} y={r.y + 4} className="splash-ob-ask-txt" textAnchor="end">
{Math.round(r.askPx).toLocaleString()}
</text>
<text x={W / 2 + 10} y={r.y + 4} className="splash-ob-bid-txt" textAnchor="start">
{Math.round(r.bidPx).toLocaleString()}
</text>
</g>
))}
<rect x={W / 2 - 72} y={MID - 14} width={144} height={28} className="splash-ob-spread" rx="4" />
</svg>
);
}
+305 -35
View File
@@ -1,4 +1,4 @@
/* GoldenChart splash — glassmorphism login + 차트 콜라주 배경 이미지 */ /* GoldenChart splash — 프리미엄 흐림 그라데이션 + 글래스 로그인 */
.splash { .splash {
position: fixed; position: fixed;
@@ -7,13 +7,15 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #06080d; background: #05070c;
color: #e8eaed; color: #e8eaed;
overflow: hidden; overflow: hidden;
font-family: var(--font, 'Noto Sans KR', 'Inter', sans-serif); font-family: var(--font, 'Noto Sans KR', 'Inter', sans-serif);
} }
.splash-photo-bg { /* ── 배경 레이어 ─────────────────────────────────────────────────────────── */
.splash-backdrop {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 0; z-index: 0;
@@ -21,24 +23,271 @@
pointer-events: none; pointer-events: none;
} }
.splash-photo-bg::before { .splash-backdrop__base {
content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
background: url('/splash-bg.png?v=3') center center / cover no-repeat; background:
filter: saturate(1.06) brightness(0.94); linear-gradient(155deg, #06080f 0%, #0a0e18 38%, #070a12 68%, #05070c 100%);
} }
.splash-photo-overlay { .splash-backdrop__mesh {
position: absolute;
inset: -20%;
opacity: 0.45;
background:
conic-gradient(
from 38deg at 58% 42%,
rgba(212, 175, 55, 0.12) 0deg,
transparent 48deg,
rgba(34, 197, 94, 0.07) 95deg,
transparent 165deg,
rgba(56, 189, 248, 0.05) 240deg,
transparent 360deg
);
filter: blur(52px);
}
.splash-backdrop__orbs {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 1;
pointer-events: none;
background:
radial-gradient(ellipse 108% 92% at 50% 50%, transparent 36%, rgba(2, 4, 8, 0.48) 100%),
linear-gradient(to bottom, rgba(2, 4, 8, 0.28) 0%, rgba(2, 4, 8, 0.06) 44%, rgba(2, 4, 8, 0.3) 100%);
} }
.splash-backdrop__orb {
position: absolute;
border-radius: 50%;
filter: blur(72px);
will-change: transform;
animation: splash-orb-float 22s ease-in-out infinite;
}
.splash-backdrop__orb--gold {
width: min(52vw, 480px);
height: min(52vw, 480px);
top: -6%;
right: 2%;
background: radial-gradient(circle, rgba(249, 226, 156, 0.32) 0%, rgba(212, 175, 55, 0.12) 45%, transparent 72%);
animation-delay: 0s;
}
.splash-backdrop__orb--teal {
width: min(44vw, 400px);
height: min(44vw, 400px);
top: 18%;
right: -6%;
background: radial-gradient(circle, rgba(34, 197, 94, 0.16) 0%, rgba(45, 212, 191, 0.06) 50%, transparent 70%);
animation-delay: -8s;
animation-duration: 24s;
}
.splash-backdrop__orb--indigo {
width: min(38vw, 340px);
height: min(38vw, 340px);
bottom: -10%;
left: -8%;
background: radial-gradient(circle, rgba(99, 102, 241, 0.14) 0%, transparent 68%);
animation-delay: -14s;
animation-duration: 26s;
}
@keyframes splash-orb-float {
0%, 100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(1.5%, -1%) scale(1.03);
}
66% {
transform: translate(-1%, 1.5%) scale(0.98);
}
}
/* ── 중앙 → 우측 상단: 캔들·호가 실루엣 ───────────────────────────────────── */
.splash-backdrop__market-flow {
position: absolute;
inset: 0;
overflow: hidden;
}
.splash-backdrop__flow-glow {
position: absolute;
left: 42%;
top: 38%;
width: min(95vw, 920px);
height: min(72vh, 640px);
transform: translate(-18%, -22%);
background: linear-gradient(
128deg,
rgba(34, 197, 94, 0.07) 0%,
rgba(212, 175, 55, 0.1) 32%,
rgba(56, 189, 248, 0.05) 58%,
transparent 78%
);
filter: blur(56px);
opacity: 0.9;
pointer-events: none;
}
.splash-backdrop__flow-inner {
position: absolute;
left: 50%;
top: 50%;
width: min(128vw, 1680px);
height: min(96vh, 920px);
transform: translate(-42%, -48%);
transform-origin: center center;
pointer-events: none;
/* 중앙(로그인 카드 부근)에서 우측 상단으로 갈수록 서서히 사라짐 */
mask-image: linear-gradient(
125deg,
#000 0%,
#000 14%,
rgba(0, 0, 0, 0.62) 32%,
rgba(0, 0, 0, 0.28) 48%,
rgba(0, 0, 0, 0.08) 58%,
transparent 72%
);
-webkit-mask-image: linear-gradient(
125deg,
#000 0%,
#000 14%,
rgba(0, 0, 0, 0.62) 32%,
rgba(0, 0, 0, 0.28) 48%,
rgba(0, 0, 0, 0.08) 58%,
transparent 72%
);
}
.splash-backdrop__chart-layer {
position: absolute;
inset: 0;
opacity: 0.38;
filter: blur(22px) saturate(0.72) brightness(0.78);
}
.splash-backdrop__orderbook-layer {
position: absolute;
right: 2%;
top: 6%;
width: min(36vw, 340px);
height: min(52vh, 480px);
opacity: 0.32;
filter: blur(14px) saturate(0.65);
}
.splash-chart-bg {
display: block;
width: 100%;
height: 100%;
}
.splash-orderbook-bg {
display: block;
width: 100%;
height: 100%;
}
.splash-ob-mid {
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 1;
stroke-dasharray: 4 6;
}
.splash-ob-ask-bar {
fill: rgba(239, 68, 68, 0.35);
}
.splash-ob-bid-bar {
fill: rgba(34, 197, 94, 0.32);
}
.splash-ob-ask-txt,
.splash-ob-bid-txt {
font-size: 9px;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
fill: rgba(255, 255, 255, 0.12);
}
.splash-ob-spread {
fill: rgba(212, 175, 55, 0.08);
stroke: rgba(212, 175, 55, 0.15);
stroke-width: 1;
}
.splash-chart-grid {
stroke: rgba(255, 255, 255, 0.04);
stroke-width: 1;
}
.splash-vol-bar {
fill: rgba(255, 255, 255, 0.06);
}
.splash-ichi--tenkan { stroke: rgba(250, 204, 21, 0.35); stroke-width: 1.2; fill: none; }
.splash-ichi--kijun { stroke: rgba(96, 165, 250, 0.3); stroke-width: 1.2; fill: none; }
.splash-ichi--senkou-a { stroke: rgba(34, 197, 94, 0.22); stroke-width: 1; fill: none; }
.splash-ichi--senkou-b { stroke: rgba(239, 68, 68, 0.2); stroke-width: 1; fill: none; }
.splash-candle-wick {
stroke: rgba(255, 255, 255, 0.2);
stroke-width: 1;
}
.splash-candle-up {
fill: rgba(34, 197, 94, 0.42);
}
.splash-candle-down {
fill: rgba(239, 68, 68, 0.38);
}
.splash-backdrop__grid {
position: absolute;
inset: 0;
opacity: 0.35;
background-image:
linear-gradient(rgba(255, 255, 255, 0.028) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.028) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 90% 80% at 50% 50%, #000 20%, transparent 85%);
-webkit-mask-image: radial-gradient(ellipse 90% 80% at 50% 50%, #000 20%, transparent 85%);
}
.splash-backdrop__grain {
position: absolute;
inset: 0;
opacity: 0.045;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 180px 180px;
}
.splash-backdrop__vignette {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 68% 58% at 50% 50%, transparent 0%, rgba(5, 7, 12, 0.42) 62%, rgba(2, 3, 6, 0.9) 100%),
linear-gradient(128deg, transparent 0%, rgba(5, 7, 12, 0.15) 55%, rgba(5, 7, 12, 0.35) 100%),
linear-gradient(180deg, rgba(5, 7, 12, 0.45) 0%, transparent 30%, transparent 70%, rgba(5, 7, 12, 0.5) 100%);
}
.splash-backdrop__spotlight {
position: absolute;
inset: 0;
background: radial-gradient(
ellipse 42% 38% at 50% 46%,
rgba(255, 255, 255, 0.06) 0%,
transparent 100%
);
}
@media (prefers-reduced-motion: reduce) {
.splash-backdrop__orb {
animation: none;
}
}
/* ── 로그인 카드 ───────────────────────────────────────────────────────────── */
.splash-center { .splash-center {
position: relative; position: relative;
z-index: 2; z-index: 2;
@@ -54,13 +303,14 @@
width: 100%; width: 100%;
padding: 36px 32px 28px; padding: 36px 32px 28px;
border-radius: 20px; border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(12, 14, 18, 0.55); background: rgba(10, 12, 18, 0.52);
backdrop-filter: blur(20px); backdrop-filter: blur(24px) saturate(1.15);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(24px) saturate(1.15);
box-shadow: box-shadow:
0 24px 64px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(212, 175, 55, 0.06) inset,
inset 0 1px 0 rgba(255, 255, 255, 0.06); 0 28px 72px rgba(0, 0, 0, 0.55),
0 8px 24px rgba(0, 0, 0, 0.35);
} }
.splash-brand { .splash-brand {
@@ -70,11 +320,11 @@
font-weight: 800; font-weight: 800;
letter-spacing: -0.02em; letter-spacing: -0.02em;
line-height: 1.1; line-height: 1.1;
background: linear-gradient(180deg, #f9e29c 0%, #d4af37 42%, #b8860b 100%); background: linear-gradient(180deg, #fce9a8 0%, #e8c547 38%, #c9a227 72%, #a67c00 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
filter: drop-shadow(0 2px 12px rgba(212, 175, 55, 0.25)); filter: drop-shadow(0 2px 16px rgba(212, 175, 55, 0.28));
} }
.splash-form { .splash-form {
@@ -87,22 +337,23 @@
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 13px 14px; padding: 13px 14px;
border-radius: 8px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.38);
color: #f3f4f6; color: #f3f4f6;
font-size: 0.95rem; font-size: 0.95rem;
transition: border-color 0.15s, box-shadow 0.15s; transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
} }
.splash-input::placeholder { .splash-input::placeholder {
color: rgba(255, 255, 255, 0.35); color: rgba(255, 255, 255, 0.32);
} }
.splash-input:focus { .splash-input:focus {
outline: none; outline: none;
border-color: rgba(212, 175, 55, 0.45); border-color: rgba(212, 175, 55, 0.5);
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.12); background: rgba(0, 0, 0, 0.48);
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.14);
} }
.splash-input:disabled { .splash-input:disabled {
@@ -121,19 +372,23 @@
margin-top: 4px; margin-top: 4px;
padding: 13px 16px; padding: 13px 16px;
border: none; border: none;
border-radius: 8px; border-radius: 10px;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
font-weight: 800; font-weight: 800;
color: #1a1408; color: #1a1408;
background: linear-gradient(180deg, #f9e29c 0%, #d4af37 48%, #c9a227 100%); background: linear-gradient(180deg, #fce9a8 0%, #e0c04a 42%, #c9a227 100%);
box-shadow: 0 4px 16px rgba(201, 162, 39, 0.35); box-shadow:
0 1px 0 rgba(255, 255, 255, 0.25) inset,
0 6px 22px rgba(201, 162, 39, 0.38);
transition: transform 0.12s, filter 0.12s, box-shadow 0.12s; transition: transform 0.12s, filter 0.12s, box-shadow 0.12s;
} }
.splash-login-btn:hover:not(:disabled) { .splash-login-btn:hover:not(:disabled) {
filter: brightness(1.05); filter: brightness(1.06);
box-shadow: 0 6px 20px rgba(201, 162, 39, 0.45); box-shadow:
0 1px 0 rgba(255, 255, 255, 0.3) inset,
0 8px 28px rgba(201, 162, 39, 0.48);
} }
.splash-login-btn:active:not(:disabled) { .splash-login-btn:active:not(:disabled) {
@@ -149,14 +404,14 @@
margin: 22px 0 0; margin: 22px 0 0;
text-align: center; text-align: center;
font-size: 0.68rem; font-size: 0.68rem;
color: rgba(255, 255, 255, 0.32); color: rgba(255, 255, 255, 0.34);
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
.splash-guest-link { .splash-guest-link {
border: none; border: none;
background: transparent; background: transparent;
color: rgba(255, 255, 255, 0.38); color: rgba(255, 255, 255, 0.4);
font-size: 0.78rem; font-size: 0.78rem;
cursor: pointer; cursor: pointer;
padding: 4px 8px; padding: 4px 8px;
@@ -164,7 +419,7 @@
} }
.splash-guest-link:hover:not(:disabled) { .splash-guest-link:hover:not(:disabled) {
color: rgba(249, 226, 156, 0.85); color: rgba(249, 226, 156, 0.9);
} }
.splash-guest-link:disabled { .splash-guest-link:disabled {
@@ -181,4 +436,19 @@
font-size: 1.65rem; font-size: 1.65rem;
margin-bottom: 22px; margin-bottom: 22px;
} }
.splash-backdrop__orb {
filter: blur(56px);
}
.splash-backdrop__flow-inner {
mask-image: radial-gradient(
ellipse 90% 85% at 24% 56%,
#000 0%,
transparent 75%
);
-webkit-mask-image: radial-gradient(
ellipse 90% 85% at 24% 56%,
#000 0%,
transparent 75%
);
}
} }
+35
View File
@@ -0,0 +1,35 @@
import type { AuthSession } from './auth';
/** 정식 로그인(회원 세션) — 게스트 입장은 제외 */
export function isFormalLogin(authUser: AuthSession | null, guestMode: boolean): boolean {
return authUser != null && !guestMode;
}
export const FORMAL_LOGIN_REQUIRED_MSG =
'매매·모의투자·투자전략·자동매매 기능은 정식 로그인 후 이용할 수 있습니다.';
/** 매매·모의·전략·알림 관련 상단 메뉴 */
export const TRADING_MENU_PAGES = new Set([
'paper',
'virtual',
'notifications',
'strategy',
'strategy-editor',
'backtest',
]);
export function isTradingMenuPage(page: string): boolean {
return TRADING_MENU_PAGES.has(page);
}
export const TRADING_SETTINGS_CATEGORIES = new Set([
'strategy',
'paper',
'virtual',
'live',
'backtest',
]);
export function isTradingSettingsCategory(cat: string): boolean {
return TRADING_SETTINGS_CATEGORIES.has(cat);
}
+483
View File
@@ -0,0 +1,483 @@
# 모의투자·매매 시그널 알림 처리 과정
goldenChart에서 **매매 시그널 알림**과 **모의투자(투자관리) 자동·수동 체결**이 어떻게 연결되는지, 처리 조건·흐름·관련 코드를 정리한 문서입니다.
> **핵심:** 시그널 **알림**과 모의 **체결**은 같은 전략 평가에서 출발하지만, **이후 파이프라인과 통과 조건이 다릅니다.**
> 알림이 많아도 체결이 적을 수 있는 구조가 정상 동작에 가깝습니다.
---
## 1. 개요
| 구분 | 역할 | 결과물 |
|------|------|--------|
| **시그널 발생** | 실시간 전략이 BUY/SELL 조건 충족 판정 | STOMP, `gc_trade_signal`, FCM, 프론트 알림 목록 |
| **모의 자동 체결** | 설정·예수금·포지션 규칙 통과 시 모의 계좌 주문 | `gc_paper_trade`, 원장·KPI 갱신 |
| **모의 수동 체결** | 사용자가 투자관리·차트에서 주문 | 동일 `gc_paper_*` 계좌 |
자동 체결은 **백엔드 전용**입니다. 브라우저·가상매매 화면이 꺼져 있어도 서버가 업비트 WS·봉 데이터를 받는 한, Match로 동기화된 live 설정이 있으면 평가·체결 시도가 이어집니다.
### 1.1 사용자 범위 (정식 로그인 vs 게스트)
| 구분 | 식별 | 매매·모의·전략·자동매매 |
|------|------|------------------------|
| **정식 로그인** | `X-User-Id` (회원 ID) | **지원** — 모의 계좌·설정·live 설정·체결 모두 `user_id` 단일 기준 (기기 무관) |
| **게스트 입장** | `X-Device-Id`만 (비로그인) | **미지원** — API 403, 프론트 `FormalLoginGate`·로그인 유도 |
- 모의 계좌: `gc_paper_account``findByUserId` 1계좌, `device_id``user:{userId}` 합성 키(UK용).
- 시그널·주문 큐: `gc_live_strategy_settings.user_id IS NOT NULL` 인 행만 평가·`OrderExecutionQueue` 적재.
- 마이그레이션 `V54`: 기존 거래·시그널 데이터 초기화, device-only live 체크 OFF.
---
## 2. 전체 아키텍처
```mermaid
flowchart TB
subgraph ingest [시세·봉]
WS[업비트 WebSocket 틱]
BB[BarBuilder 1m 확정·상위봉 전파]
TS[Ta4jStorage]
WS --> BB --> TS
end
subgraph eval [전략 평가]
BCE[BarCloseStrategyEvaluationService]
LSS[LiveStrategyScheduler 3초]
LSE[LiveStrategyEvaluator]
TS --> BCE
TS --> LSS
BCE --> LSE
LSS --> LSE
end
subgraph signal_out [시그널 산출 - 알림 경로]
STOMP[TradingWebSocketBroker STOMP]
TSS[TradeSignalService.save]
FCM[FcmPushService]
LSE -->|BUY/SELL| BCE
LSE -->|BUY/SELL| LSS
BCE --> STOMP
BCE --> TSS
LSS --> STOMP
LSS --> TSS
TSS --> FCM
end
subgraph order_path [주문 경로 - 체결]
OEQ[OrderExecutionQueue]
TES[TradingExecutionService]
PTS[PaperTradingService.tryExecuteOnSignal]
BCE -->|deviceId != null| OEQ
LSS -->|deviceId != null| OEQ
OEQ --> TES
TES --> PTS
end
subgraph fe [프론트]
LSN[LiveSignalNotifier / STOMP]
TNC[TradeNotificationContext]
PTP[PaperTradingPage / TradeOrderPanel]
STOMP --> LSN --> TNC
PTS --> PTP
end
```
---
## 3. 사전 설정 (DB·앱)
### 3.1 종목별 실시간 전략 — `gc_live_strategy_settings`
| 필드 | 설명 |
|------|------|
| `market` | 종목 코드 (예: `KRW-BTC`) |
| `user_id` / `device_id` | **회원:** `user_id`만 저장, `device_id=null` · **비회원:** `device_id`만 |
| `strategy_id` | 사용할 `GcStrategy` |
| `is_live_check` | 실시간 체크 ON → 평가·시그널 대상 |
| `execution_type` | `CANDLE_CLOSE`(봉 마감) 또는 `REALTIME_TICK`(3초 틱) |
| `position_mode` | `LONG_ONLY`(포지션 추적) 또는 `SIGNAL_ONLY`(규칙만) |
| `candle_type` | UI 기본 분봉; 실제 평가 분봉은 DSL `StrategyConditionTimeframeService` 기준 |
**저장 API:** `PUT /api/strategy/settings` (`LiveStrategySettingsService`)
- 가상투자 Match 시 `syncVirtualTargetsToBackend()` → 종목별 `isLiveCheck=true` 일괄 반영 (`frontend/src/utils/virtualLiveStrategySync.ts`)
- 차트·설정의 전역 실시간 체크는 `gc_app_settings.live_strategy_check` / `live_strategy_id` 와 연동
### 3.2 모의투자·실행 모드 — `gc_app_settings`
| 필드 | 자동 체결 관련 |
|------|----------------|
| `paper_trading_enabled` | 모의투자 마스터 ON |
| `paper_auto_trade_enabled` | **전략 시그널 → 모의 자동 체결** ON |
| `paper_auto_trade_budget_pct` | 자동 매수 시 가용 현금 대비 사용 비율 (기본 95%) |
| `paper_initial_capital` | 초기 자본 |
| `paper_fee_rate_pct` / `paper_slippage_pct` | 수수료·슬리피지 |
| `paper_min_order_krw` | 최소 주문 금액 (기본 5,000원) |
| `trading_mode` | `PAPER` / `LIVE` / `BOTH` — 자동 체결 대상 분기 |
| `live_strategy_check` | 전역 실시간 체크 (종목별 `is_live_check` 없을 때 보조 조건) |
**조회·저장:** `GET/POST /api/app-settings` (`X-Device-Id`, 로그인 시 `X-User-Id`)
### 3.3 모의 계좌 — `gc_paper_account`
- **키:** `device_id` (HTTP `X-Device-Id`; 없으면 `anonymous`)
- 로그인 사용자도 요청 헤더의 `device_id`로 계좌를 구분 ( `user_id`는 부가 정보)
- 보유: `gc_paper_position`, 체결: `gc_paper_trade`, 한도: `gc_paper_symbol_allocation`
---
## 4. 시그널 발생 상세
### 4.1 데이터·구독 전제
1. `LiveStrategyStartupRunner` — 기동 시 `is_live_check=true` 종목 WS·Ta4j 고정 구독
2. `BarBuilder.onTick` — 1m 확정 후 상위 분봉(5m 등) 전파
3. `StrategyConditionTimeframeService` — 전략 DSL에 포함된 분봉만 평가
### 4.2 방식 A — 봉 마감 (`CANDLE_CLOSE`)
**트리거:** `BarBuilder.commitBar()``BarCloseStrategyEvaluationService.onMaturedBarClose()`
**진입 조건 (종목·설정 행마다):**
1. `is_live_check = true` 이고 `strategy_id` 존재
2. `execution_type = CANDLE_CLOSE` (또는 마감 시 `evaluateRealtimeAtClose` 보조)
3. `usesTimeframe(strategyId, candleType)` — DSL에 해당 분봉 포함
4. 동일 `market:candleType:barEndEpoch` **중복 평가 방지** (`evaluatedBarCloseKeys`)
**평가:** `LiveStrategyEvaluator.evaluateSettingOnCandleClose()` — **확정된 직전 봉 인덱스**에서 1회
### 4.3 방식 B — 실시간 틱 (`REALTIME_TICK`)
**트리거:** `LiveStrategyScheduler`**3초 `fixedDelay`** (`@Scheduled`)
**조건:** `findAllByIsLiveCheckTrue()``execution_type = REALTIME_TICK`
**평가:** `evaluateSettingRealtimeTick()`**진행 중 봉** `series.getEndIndex()`
동일 `(market, candleType, index)` 중복 시그널 방지
### 4.4 시그널 판정 (`LiveStrategyEvaluator` + `StrategySignalDeterminer`)
| `position_mode` | BUY | SELL |
|-----------------|-----|------|
| **LONG_ONLY** | 진입 규칙 + 가상 `TradingRecord` 미보유 | 청산 규칙 + 보유 중 |
| **SIGNAL_ONLY** | `entryRule` 충족 | `exitRule` 충족 |
`LONG_ONLY`에서 이미 보유인데 BUY, 무포지션인데 SELL이면 evaluator 내부에서 **`NONE`으로 정리**할 수 있음 (캐시된 포지션 상태 기준).
결과: `"BUY"` | `"SELL"` | `"NONE"`
---
## 5. 시그널 후처리 (알림 파이프라인)
BUY/SELL이 확정되면 **설정 행(`GcLiveStrategySettings`)마다** 아래가 순서대로 실행됩니다.
### 5.1 STOMP 실시간 푸시
| 항목 | 내용 |
|------|------|
| 토픽 | `/sub/charts/{market}/{candleType}` |
| 페이로드 | `CandleBarDto` — OHLCV + `signal`, `strategyId`, `userId`, `deviceId`, `executionType` |
| 발행 | `TradingWebSocketBroker.publish()` |
### 5.2 DB 이력 저장
`TradeSignalService.save(deviceId, userId, market, strategyId, …)`**`gc_trade_signal`**
- **로그인:** `user_id` 저장, `device_id`는 null일 수 있음
- **비회원:** `device_id` 저장
**API:** `GET /api/trade-signals` — 로그인 시 `user_id`, 비로그인 시 `device_id` 기준 목록
### 5.3 FCM 푸시 (선택)
`FcmPushService.sendTradeSignalIfEnabled()` — 앱 알림 설정·토큰 등록 시
로그인 사용자는 **userId** 기준 발송 (deviceId null 허용)
### 5.4 주문 큐 적재 (자동 체결 **시도**만)
```java
// BarCloseStrategyEvaluationService, LiveStrategyScheduler 공통
if (s.getDeviceId() != null) {
orderExecutionQueue.submitSignal(
s.getDeviceId(), s.getUserId(), market,
s.getStrategyId(), signal, price);
}
```
> **주문 큐:** `user_id`가 있는 live 설정만 `OrderExecutionQueue`에 적재합니다. (게스트 device-only 설정은 평가 대상에서 제외)
---
## 6. 프론트 — 매매 시그널 알림
### 6.1 STOMP 수신
| 컴포넌트 | 역할 |
|----------|------|
| `useLiveStrategyMarkers` | `/sub/charts/...` 구독, `signal` 필드 처리 |
| `signalBelongsToCurrentSession` | `userId` 또는 `deviceId` 소유자 필터 (`tradeSignalOwnership.ts`) |
| `LiveSignalNotifier` | `App.tsx` 전역, `monitoredMarkets` 대상 |
**구독 대상 (`monitoredMarkets`):**
- 가상투자 실행 중 → 가상투자 대상 종목
- 아니면 → `liveStrategyCheck` + `liveStrategyId` + 관심종목
### 6.2 알림 Context
`TradeNotificationContext`:
- STOMP 수신 → `addNotification()` (미확인 배지·토스트·사운드)
- 20초마다 `loadTradeSignals()` → 서버 이력과 병합
- 알림 ID: `{market}:{candleTime}:{signalType}`
- **알림 목록 화면:** `TradeNotificationListPage` (메뉴 알림목록)
### 6.3 알림 vs 체결 (프론트)
- 프론트는 시그널 수신 시 **`placePaperOrder`를 호출하지 않음** (과거 STOMP→주문 훅 제거됨)
- 투자관리·가상매매 UI는 **모니터링·수동 주문·설정**; 자동 체결은 백엔드만
---
## 7. 모의 자동매매 실행 파이프라인
### 7.1 주문 큐
`OrderExecutionQueue` — 단일 워커 스레드 + `RateLimiter` (업비트 429 완화)
| 단계 | 동작 |
|------|------|
| `submitSignal` | `OrderRequest.strategySignal` 큐 적재 |
| `executeNow` | `deviceId` blank면 **즉시 return** |
| 실행 | `TradingExecutionService.executeSignal()` |
### 7.2 실행 대상 분기 — `TradingExecutionService`
```text
trading_mode:
PAPER → paperTradingService.tryExecuteOnSignal (paper_trading_enabled 시)
LIVE → liveTradingService.tryExecuteOnSignal
BOTH → 둘 다 시도
```
### 7.3 모의 체결 본체 — `PaperTradingService.tryExecuteOnSignal`
**진입 전 필수 (하나라도 실패 시 return, 예외는 warn 로그):**
| # | 조건 |
|---|------|
| 1 | `deviceId` not blank |
| 2 | `signalType` ∈ {BUY, SELL} |
| 3 | `paper_trading_enabled = true` |
| 4 | `paper_auto_trade_enabled = true` |
| 5 | `live_strategy_check = true` **OR** 해당 `market``is_live_check=true` 설정 존재 |
| 6 | `trading_mode`가 PAPER 또는 BOTH (`TradingExecutionService` 단계) |
**포지션 모드 `LONG_ONLY` (실제 계좌 보유 기준):**
| 시그널 | 스킵 조건 |
|--------|-----------|
| BUY | 해당 종목 **이미 보유** (`quantity > 0`) |
| SELL | 해당 종목 **미보유** |
**매수 금액 계산:**
```text
budget = (cash_balance - reserved_cash) × paper_auto_trade_budget_pct / 100
execPrice = price × (1 + slippage%)
qty = budget / (execPrice × (1 + fee%))
```
| # | 매수 스킵 |
|---|-----------|
| 7 | `budget < paper_min_order_krw` |
| 8 | `qty × execPrice < min_order` |
**매도:** 보유 수량 전량(가용 수량, 미체결 매도 예약 제외)
**체결:** `executeTrade(..., source="STRATEGY", autoTrade=true)`
- 종목 한도: `PaperAllocationService.validateBuy``getOrDefault`로 한도 행 없으면 초기자본 규모 한도 자동 생성
- 체결 기록: `gc_paper_trade`, 원장: `PaperLedgerService`
---
## 8. 모의 수동매매 (시그널과 별도)
**경로:** `TradeOrderPanel``POST /api/paper/orders``PaperTradingService.placeOrder`
| 구분 | 자동 (`tryExecuteOnSignal`) | 수동 (`placeOrder`) |
|------|----------------------------|---------------------|
| `paper_auto_trade_enabled` | 필수 ON | 불필요 |
| 예산 | 전역 `paper_auto_trade_budget_pct` | 요청 수량·금액·한도 |
| `source` | `STRATEGY` | `MANUAL` 등 |
| 한도 검증 | `autoTrade=true` | `autoTrade=false` |
수동 주문도 `paper_trading_enabled` 및 동일 계좌·한도 규칙을 따릅니다.
---
## 9. 가상투자 ↔ 백엔드 동기화
가상투자 **Match(실행)** 시:
1. `syncVirtualTargetsToBackend(targets, session, isLiveCheck=true)`
2. 종목별 `saveLiveStrategySettings``isLiveCheck`, `executionType`, `positionMode`
3. `putPaperAllocationsBulk` — 종목별 한도 행(베스트 에포트)
4. 전략 분봉 pin — `pinStrategyEvaluationTimeframes`
**중지 시:** `stopVirtualLiveOnBackend``isLiveCheck=false`
서버는 이후 **BarClose / LiveStrategyScheduler**만으로 평가·시그널·(조건 충족 시) 체결을 진행합니다.
---
## 10. 처리 조건 요약표
### 10.1 시그널 알림이 쌓이려면
| 조건 | 필수 |
|------|:----:|
| `gc_live_strategy_settings.is_live_check = true` | ✓ |
| `strategy_id` 지정 | ✓ |
| DSL에 해당 `candle_type` 포함 | ✓ |
| Ta4jStorage에 봉 데이터 존재 | ✓ |
| 전략 규칙 BUY/SELL 판정 | ✓ |
**DB 저장·STOMP·(설정 시) FCM·프론트 알림** (deviceId 없어도 가능)
### 10.2 모의 자동 체결이 되려면
위 시그널 조건 **+** 아래 **전부** (실무상):
| 조건 | 필수 |
|------|:----:|
| live 설정 `user_id != null` (주문 큐 진입) | ✓ |
| `paper_trading_enabled` | ✓ |
| `paper_auto_trade_enabled` | ✓ |
| `trading_mode` ∈ {PAPER, BOTH} | ✓ |
| `live_strategy_check` OR 종목 `is_live_check` | ✓ |
| LONG_ONLY: BUY=미보유, SELL=보유 | ✓ |
| 예수금·한도·최소주문금액 | ✓ |
---
## 11. 알림은 많은데 체결이 적은 대표 사례
| 현상 | 원인 |
|------|------|
| 미확인 알림 수백 건, 체결 3건 | 종목 다수 × 5분봉 × 매수·매도 시그널; 체결은 예수금·포지션 규칙으로 소수만 통과 |
| 연속 매수 알림, 체결 없음 | **예수금 소진** — 자동 매수가 남은 현금의 95%씩 사용 (1천만 → 약 3종목 후 5천원 미만) |
| 매도 알림만 많음 | **LONG_ONLY** + 미보유 → 매도 시그널은 알림만, 체결 스킵 |
| 같은 종목 매수 알림 반복 | 이미 보유 → BUY 체결 스킵 (알림은 evaluator/DB 경로에 따라 발생 가능) |
| 게스트 입장 | 매매 API 403, STOMP 시그널 구독 없음 |
| KPI 1천만, 거래 탭 과거 3건 | **계좌 초기화** 후 현금 복구, **체결 이력은 유지** |
| 자동매매 OFF | `paper_auto_trade_enabled=false` — 수동만 가능 |
**예수금 소진 예 (초기 1천만, budget 95%):**
1. 1차 매수 ≈ 950만 → 잔액 ≈ 50만
2. 2차 ≈ 47.5만 → 잔액 ≈ 2.5만
3. 3차 ≈ 2.4만 → 잔액 ≈ 1천원대 → 이후 BUY 시그널 전부 `budget < min_order` 스킵
---
## 12. 엔드투엔드 시퀀스 (봉 마감 + 자동 모의)
```mermaid
sequenceDiagram
participant UB as 업비트 WS
participant BB as BarBuilder
participant BCE as BarCloseEval
participant LSE as LiveStrategyEvaluator
participant TSS as TradeSignalService
participant OEQ as OrderExecutionQueue
participant PTS as PaperTradingService
participant FE as 프론트 STOMP
UB->>BB: tick
BB->>BCE: onMaturedBarClose (5m 등)
BCE->>LSE: evaluateSettingOnCandleClose
LSE-->>BCE: BUY|SELL|NONE
alt BUY or SELL
BCE->>FE: STOMP CandleBarDto.signal
BCE->>TSS: save → gc_trade_signal
alt setting.deviceId != null
BCE->>OEQ: submitSignal
OEQ->>PTS: tryExecuteOnSignal
alt 모든 체결 조건 통과
PTS->>PTS: executeTrade STRATEGY
else 조건 실패
PTS-->>PTS: silent return
end
end
end
```
---
## 13. 주요 클래스·파일
### 백엔드
| 영역 | 클래스 |
|------|--------|
| 봉·마감 평가 | `BarBuilder`, `BarCloseStrategyEvaluationService` |
| 틱 평가 | `LiveStrategyScheduler` |
| 전략 판정 | `LiveStrategyEvaluator`, `StrategySignalDeterminer` |
| 시그널 저장 | `TradeSignalService`, `GcTradeSignal` |
| STOMP | `TradingWebSocketBroker` |
| 주문 큐 | `OrderExecutionQueue`, `OrderRequest` |
| 실행 분기 | `TradingExecutionService`, `TradingMode` |
| 모의 체결 | `PaperTradingService`, `PaperAllocationService` |
| Live 설정 | `LiveStrategySettingsService`, `GcLiveStrategySettings` |
| 설정 | `AppSettingsService`, `GcAppSettings` |
### 프론트
| 영역 | 파일 |
|------|------|
| 전역 STOMP 알림 | `LiveSignalNotifier.tsx`, `useLiveStrategyMarkers.ts` |
| 알림 상태 | `TradeNotificationContext.tsx`, `TradeNotificationListPage.tsx` |
| 소유자 필터 | `tradeSignalOwnership.ts` |
| 투자관리 UI | `PaperTradingPage.tsx`, `TradeOrderPanel.tsx` |
| 가상 동기화 | `virtualLiveStrategySync.ts` |
| API | `backendApi.ts``loadTradeSignals`, paper APIs |
### DB (핵심 테이블)
| 테이블 | 용도 |
|--------|------|
| `gc_live_strategy_settings` | 종목별 실시간 전략·체크 ON |
| `gc_trade_signal` | 매매 시그널 알림 이력 |
| `gc_app_settings` | 모의·자동매매·실행모드 전역 |
| `gc_paper_account` | 모의 계좌 (device_id) |
| `gc_paper_trade` | 체결 내역 |
| `gc_paper_symbol_allocation` | 종목별 투자 한도 |
---
## 14. 운영·디버깅 체크리스트
1. **설정 → 모의투자:** 활성화 ON, 자동매매 ON, 실행 대상 PAPER/BOTH
2. **가상투자 Match:** 대상 종목 `isLiveCheck` 백엔드 반영 여부
3. **예수금:** 투자관리 KPI `주문가능`, 체결 후 `cash_after`
4. **포지션:** LONG_ONLY 시 매도 알림 vs 실제 보유
5. **로그인:** `device_id` null 이슈 — 자동 체결 기대 시 비회원 테스트 또는 백엔드 보완 여부 확인
6. **로그 키워드:** `[TradeSignal] saved`, `[Paper] STRATEGY BUY`, `[Paper] auto trade failed`, `[OrderExecutionQueue]`
---
## 15. 관련 문서
- [전략편집기 로직 및 동작흐름.md](./전략편집기%20로직%20및%20동작흐름.md) — §9 시그널 감지, §10 시그널 발행
- [docs/모의투자_투자금_관리_설계.md](./docs/모의투자_투자금_관리_설계.md) — 계좌·한도·원장 설계
- [backend/FCM_BACKEND.md](./backend/FCM_BACKEND.md) — 푸시 연동
- [db_struct.md](./db_struct.md) — 테이블 상세
---
*문서 기준: goldenChart 저장소 백엔드·프론트 구현 (자동 체결 백엔드 전용, 회원 live 설정 `device_id=null` 주문 큐 분기 포함).*