모의투자 로직 변경
This commit is contained in:
@@ -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,42 +42,32 @@ 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 로 덮어쓰기
|
java.util.Map<String, java.util.Map<String, Object>> dbParams =
|
||||||
// (요청에 params 없으면 DB 값만 사용 → 사용자가 변경한 파라미터 항상 반영)
|
indicatorSettingsService.getAll(userId, null);
|
||||||
if (userId != null || deviceId != null) {
|
if (!dbParams.isEmpty()) {
|
||||||
java.util.Map<String, java.util.Map<String, Object>> dbParams =
|
java.util.Map<String, java.util.Map<String, Object>> merged =
|
||||||
indicatorSettingsService.getAll(userId, deviceId);
|
new java.util.HashMap<>(dbParams);
|
||||||
if (!dbParams.isEmpty()) {
|
if (req.getIndicatorParams() != null) {
|
||||||
java.util.Map<String, java.util.Map<String, Object>> merged =
|
req.getIndicatorParams().forEach((type, params) ->
|
||||||
new java.util.HashMap<>(dbParams);
|
merged.merge(type, params, (dbP, reqP) -> {
|
||||||
if (req.getIndicatorParams() != null) {
|
java.util.Map<String, Object> m = new java.util.HashMap<>(dbP);
|
||||||
req.getIndicatorParams().forEach((type, params) ->
|
m.putAll(reqP);
|
||||||
merged.merge(type, params, (dbP, reqP) -> {
|
return m;
|
||||||
java.util.Map<String, Object> m = new java.util.HashMap<>(dbP);
|
})
|
||||||
m.putAll(reqP);
|
);
|
||||||
return m;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-31
@@ -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);
|
||||||
|
|||||||
+3
-2
@@ -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;
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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%
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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` 주문 큐 분기 포함).*
|
||||||
Reference in New Issue
Block a user