🎯 κ΅¬ν˜„ λͺ©ν‘œ (Context & Goal) ν˜„μž¬ κ΅¬μΆ•λœ Backend (Spring Boot + Ta4j)와 Frontend (Lightweight Charts) μ‹œμŠ€ν…œμ— "μ‹€μ‹œκ°„ λ§€λ§€μ „λž΅ μ‹€ν–‰ μ„€μ • κΈ°λŠ₯"을 λ„μž…ν•©λ‹ˆλ‹€. μœ μ €κ°€ ν™”λ©΄μ—μ„œ μ„€μ •ν•œ μ˜΅μ…˜(μ‹€ν–‰ 타이밍, λͺ¨λ‹ˆν„°λ§ μ—¬λΆ€)에 따라 λ°±μ—”λ“œ μ—°μ‚° 주기와 ν”„λ‘ νŠΈμ—”λ“œ 차트 μ‹œκ·Έλ„ 마컀(setMarkers)κ°€ 유기적으둜 μ—°λ™λ˜λ„λ‘ μ•„ν‚€ν…μ²˜λ₯Ό ν™•μž₯ν•΄ μ£Όμ„Έμš”. πŸ› οΈ 1. μš”κ΅¬μ‚¬ν•­ λͺ…μ„Έ 및 μ •μ±… (Requirements) β‘  μ„€μ • ν™”λ©΄ UI ꡬ성 (Frontend) μ‹€μ‹œκ°„ μ „λž΅ 체크 ν† κΈ€: ON / OFF μŠ€μœ„μΉ˜ λ§€λ§€μ „λž΅ μ‹€ν–‰ 방식 λΌλ””μ˜€ λ²„νŠΌ: (μ‹€μ‹œκ°„ μ „λž΅ 체크가 ON 일 λ•Œλ§Œ ν™œμ„±ν™”) A. 봉 마감 직후 체크 (At Candle Close) B. μ‹€μ‹œκ°„ ν‹±(μž₯쀑) 체크 (Intraday Tick) 주의: 이 섀정값듀은 μ „μ—­ μƒνƒœ(Redux/Zustand λ“±)둜 κ΄€λ¦¬λ˜λ©°, λ³€κ²½ μ¦‰μ‹œ λ°±μ—”λ“œλ‘œ REST APIλ₯Ό 톡해 λ°˜μ˜λ˜μ–΄ λŸ°νƒ€μž„μ— λ™μ μœΌλ‘œ κ°μ‹œ μŠ€λ ˆλ“œκ°€ μ œμ–΄λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. β‘‘ λ°±μ—”λ“œ μ‹€ν–‰ μ½”μ–΄ μ œμ–΄ μ •μ±… (Backend Threading) μ„€μ •κ°’ OFF μ‹œ: ν•΄λ‹Ή μ’…λͺ© 및 μœ μ €μ— λŒ€ν•œ 기술적 뢄석 μ§€ν‘œ 및 μ „λž΅ 체크 루프λ₯Ό μ™„μ „νžˆ μ€‘λ‹¨ν•˜μ—¬ μ„œλ²„ μžμ›μ„ λ°©μ–΄ν•©λ‹ˆλ‹€. μ„€μ •κ°’ ON + 방식 A (봉 마감 직후): μ—…λΉ„νŠΈ μ›Ήμ†ŒμΌ“μ—μ„œ λ“€μ–΄μ˜¨ ν‹± λ°μ΄ν„°λ‘œ 뢄봉 쑰립 쀑, '뢄이 λ°”λ€Œμ–΄ μƒˆλ‘œμš΄ Barκ°€ μΆ”κ°€λ˜λŠ” μ‹œμ (addBar)'μ—λ§Œ ν•΄λ‹Ή μ’…λͺ©μ˜ Strategy.isSatisfied(lastMaturedBarIndex)λ₯Ό 1회 νŒμ •ν•©λ‹ˆλ‹€. μ„€μ •κ°’ ON + 방식 B (μ‹€μ‹œκ°„ ν‹± 체크): λ°±μ—”λ“œμ˜ 3초 μ£ΌκΈ° λΌμš΄λ“œ 둜빈 μŠ€μΌ€μ€„λŸ¬κ°€ λŒμ•„κ°ˆ λ•Œ, 아직 λ§ˆκ°λ˜μ§€ μ•Šμ€ ν˜„μž¬ μ§„ν–‰ 쀑인 μΊ”λ“€ 인덱슀(series.getEndIndex())λ₯Ό κΈ°μ€€μœΌλ‘œ μ „λž΅ 만쑱 μ—¬λΆ€λ₯Ό κ°•μ œ νŒμ •ν•©λ‹ˆλ‹€. πŸ’Ύ 2. 데이터 ꡬ쑰 및 μΈν„°νŽ˜μ΄μŠ€ 섀계 (Data Struct) β‘  [PUT] /api/strategy/settings (μ „λž΅ μ„€μ • λ³€κ²½ API) JSON { "market": "KRW-BTC", "isLiveCheck": true, // μ‹€μ‹œκ°„ μ „λž΅ 체크 ON/OFF "executionType": "CANDLE_CLOSE" // "CANDLE_CLOSE" (방식A) λ˜λŠ” "REALTIME_TICK" (방식B) } β‘‘ [STOMP WebSocket] ν™•μž₯된 μ‹€μ‹œκ°„ 데이터 ν”„λ‘œν† μ½œ λ°±μ—”λ“œμ—μ„œ ν”„λ‘ νŠΈμ—”λ“œλ‘œ μ‹€μ‹œκ°„ 캔듀을 λ°œν–‰ν•  λ•Œ, μ„€μ • μƒνƒœμ™€ 맀칭된 μ‹œκ·Έλ„ 유무λ₯Ό λͺ…ν™•νžˆ DTO 포맷에 μ£Όμž…ν•©λ‹ˆλ‹€. Topic: /sub/charts/{market}/{type} Payload (JSON): JSON { "t": 1779229260, "o": 92080000.0, "h": 92120000.0, "l": 92010000.0, "c": 92050000.0, "v": 1.845, "rsi": 38.40, "signal": "BUY" // "BUY", "SELL", "NONE" 쀑 ν•˜λ‚˜ (μ „λž΅ 체크가 OFFμ΄κ±°λ‚˜ 쑰건 미달 μ‹œ 무쑰건 "NONE") } πŸ’» 3. AI κ΅¬ν˜„ κ°€μ΄λ“œλΌμΈ μ½”λ“œ (Reference Code for AI) [Backend] 가상 μ½”λ“œλ₯Ό μ°Έκ³ ν•˜μ—¬ 동적 λΆ„κΈ° μ„œλΉ„μŠ€ λ ˆμ΄μ–΄λ₯Ό μž‘μ„±ν•΄ μ£Όμ„Έμš”. Java @Service @RequiredArgsConstructor public class LiveStrategyEvaluator { private final Ta4jStorage ta4jStorage; private final UserStrategySettingsRepository settingsRepository; // 방식 A: 봉 마감 μ—”μ§„μ—μ„œ 트리거됨 public String evaluateCandleClose(String market, String candleType, int maturedIndex) { UserSetting setting = settingsRepository.findByMarket(market); if (!setting.isLiveCheck() || !setting.getExecutionType().equals("CANDLE_CLOSE")) { return "NONE"; } Strategy strategy = ta4jStorage.getStrategy(market); BarSeries series = ta4jStorage.getSeries(market, candleType); if (strategy.getEntryRule().isSatisfied(maturedIndex)) return "BUY"; if (strategy.getExitRule().isSatisfied(maturedIndex)) return "SELL"; return "NONE"; } // 방식 B: 3초 μŠ€μΌ€μ€„λŸ¬ μ—”μ§„μ—μ„œ 트리거됨 public String evaluateRealtimeTick(String market, String candleType) { UserSetting setting = settingsRepository.findByMarket(market); if (!setting.isLiveCheck() || !setting.getExecutionType().equals("REALTIME_TICK")) { return "NONE"; } BarSeries series = ta4jStorage.getSeries(market, candleType); Strategy strategy = ta4jStorage.getStrategy(market); int currentIndex = series.getEndIndex(); // λ§ˆκ°λ˜μ§€ μ•Šμ€ ν˜„μž¬ μ§„ν–‰ν˜• μΊ”λ“€ if (strategy.getEntryRule().isSatisfied(currentIndex)) return "BUY"; if (strategy.getExitRule().isSatisfied(currentIndex)) return "SELL"; return "NONE"; } } [Frontend] STOMP 데이터λ₯Ό λ°›μ•„ Lightweight Charts에 마컀λ₯Ό λˆ„μ  μ²˜λ¦¬ν•˜λŠ” ν›…(Hook) λ˜λŠ” μ»΄ν¬λ„ŒνŠΈ λ‘œμ§μ„ μž‘μ„±ν•΄ μ£Όμ„Έμš”. JavaScript // ν”„λ‘ νŠΈμ—”λ“œ μΊ”λ“€ 및 마컀 μ‹€μ‹œκ°„ μ—…λ°μ΄νŠΈ μ œμ–΄λΆ€ let activeMarkers = []; // μ°¨νŠΈμ— ν‘œμ‹œλœ 마컀 μƒνƒœ μΊμ‹œ λ°°μ—΄ stompClient.subscribe(`/sub/charts/${market}/${candleType}`, (message) => { const data = JSON.parse(message.body); // 1. κΈ°λ³Έ μΊ”λ“€ λ Œλ”λ§ 무쑰건 μ‹€ν–‰ candleSeries.update({ time: data.t, open: data.o, high: data.h, low: data.l, close: data.c }); // 2. λ°±μ—”λ“œμ—μ„œ μ£Όμž…λœ signal νŒλ³„ (NONE이 아닐 λ•Œλ§Œ 차트 μ˜€λ²„λ ˆμ΄ 마컀 μΆ”κ°€) if (data.signal !== 'NONE') { // 쀑볡 νƒ€μž„μŠ€νƒ¬ν”„ 마컀 μ‚½μž… λ°©μ§€ (방식 B μž₯쀑 ν‹± 동적 체크 λŒ€μ‘) const isDuplicate = activeMarkers.some(m => m.time === data.t && m.signalType === data.signal); if (!isDuplicate) { const newMarker = { time: data.t, position: data.signal === 'BUY' ? 'belowBar' : 'aboveBar', color: data.signal === 'BUY' ? '#26a69a' : '#ef5350', shape: data.signal === 'BUY' ? 'arrowUp' : 'arrowDown', text: data.signal === 'BUY' ? 'μ‹€μ‹œκ°„ 맀수' : 'μ‹€μ‹œκ°„ 맀도', signalType: data.signal // μ»€μŠ€ν…€ μ‹λ³„μž }; activeMarkers.push(newMarker); candleSeries.setMarkers(activeMarkers); } } });