모의투자 로직 변경

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