매매 시그널 알림 화면 수정
This commit is contained in:
@@ -67,6 +67,11 @@ public class GcAppSettings {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private String displayTimezone = "Asia/Seoul";
|
private String displayTimezone = "Asia/Seoul";
|
||||||
|
|
||||||
|
/** 차트 하단 시간축 표시 포맷 (예: yyyy-MM-dd HH:mm) */
|
||||||
|
@Column(name = "chart_time_format", length = 64, nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private String chartTimeFormat = "MM-dd HH:mm";
|
||||||
|
|
||||||
/** 캔들 색상 JSON (frontend MainChartStyle 구조) */
|
/** 캔들 색상 JSON (frontend MainChartStyle 구조) */
|
||||||
@Column(name = "main_chart_style_json", columnDefinition = "JSON")
|
@Column(name = "main_chart_style_json", columnDefinition = "JSON")
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ public class AppSettingsService {
|
|||||||
Boolean.parseBoolean(d.get("defaultLogScale").toString()));
|
Boolean.parseBoolean(d.get("defaultLogScale").toString()));
|
||||||
if (d.containsKey("defaultLayoutId")) s.setDefaultLayoutId((String) d.get("defaultLayoutId"));
|
if (d.containsKey("defaultLayoutId")) s.setDefaultLayoutId((String) d.get("defaultLayoutId"));
|
||||||
if (d.containsKey("displayTimezone")) s.setDisplayTimezone((String) d.get("displayTimezone"));
|
if (d.containsKey("displayTimezone")) s.setDisplayTimezone((String) d.get("displayTimezone"));
|
||||||
|
if (d.containsKey("chartTimeFormat")) s.setChartTimeFormat(normalizeChartTimeFormat((String) d.get("chartTimeFormat")));
|
||||||
if (d.containsKey("mainChartStyle")) s.setMainChartStyleJson(toJson(d.get("mainChartStyle")));
|
if (d.containsKey("mainChartStyle")) s.setMainChartStyleJson(toJson(d.get("mainChartStyle")));
|
||||||
if (d.containsKey("syncOptions")) s.setSyncOptionsJson(toJson(d.get("syncOptions")));
|
if (d.containsKey("syncOptions")) s.setSyncOptionsJson(toJson(d.get("syncOptions")));
|
||||||
if (d.containsKey("btAutoPopup")) s.setBtAutoPopup(
|
if (d.containsKey("btAutoPopup")) s.setBtAutoPopup(
|
||||||
@@ -188,6 +189,7 @@ public class AppSettingsService {
|
|||||||
m.put("defaultLogScale", s.getDefaultLogScale() != null ? s.getDefaultLogScale() : false);
|
m.put("defaultLogScale", s.getDefaultLogScale() != null ? s.getDefaultLogScale() : false);
|
||||||
m.put("defaultLayoutId", s.getDefaultLayoutId() != null ? s.getDefaultLayoutId() : "1");
|
m.put("defaultLayoutId", s.getDefaultLayoutId() != null ? s.getDefaultLayoutId() : "1");
|
||||||
m.put("displayTimezone", s.getDisplayTimezone() != null ? s.getDisplayTimezone() : "Asia/Seoul");
|
m.put("displayTimezone", s.getDisplayTimezone() != null ? s.getDisplayTimezone() : "Asia/Seoul");
|
||||||
|
m.put("chartTimeFormat", s.getChartTimeFormat() != null ? s.getChartTimeFormat() : "MM-dd HH:mm");
|
||||||
m.put("mainChartStyle", parseJson(s.getMainChartStyleJson()));
|
m.put("mainChartStyle", parseJson(s.getMainChartStyleJson()));
|
||||||
m.put("syncOptions", parseJson(s.getSyncOptionsJson()));
|
m.put("syncOptions", parseJson(s.getSyncOptionsJson()));
|
||||||
m.put("btAutoPopup", s.getBtAutoPopup() != null ? s.getBtAutoPopup() : true);
|
m.put("btAutoPopup", s.getBtAutoPopup() != null ? s.getBtAutoPopup() : true);
|
||||||
@@ -282,4 +284,11 @@ public class AppSettingsService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String normalizeChartTimeFormat(String raw) {
|
||||||
|
if (raw == null) return "MM-dd HH:mm";
|
||||||
|
String t = raw.trim();
|
||||||
|
if (t.isEmpty() || !t.matches(".*[yMdHms].*")) return "MM-dd HH:mm";
|
||||||
|
return t.length() > 64 ? t.substring(0, 64) : t;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-3
@@ -69,7 +69,7 @@ public class StrategyConditionTimeframeService {
|
|||||||
|
|
||||||
Set<String> fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson());
|
Set<String> fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson());
|
||||||
if (!fromFlowLayout.isEmpty()) {
|
if (!fromFlowLayout.isEmpty()) {
|
||||||
return fromFlowLayout;
|
return sanitizeEvaluationTimeframes(fromFlowLayout, strategy);
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<String> buyOut = new LinkedHashSet<>();
|
Set<String> buyOut = new LinkedHashSet<>();
|
||||||
@@ -79,10 +79,10 @@ public class StrategyConditionTimeframeService {
|
|||||||
|
|
||||||
// 매수 START 분봉 우선 — 매도 쪽 레거시 1m TIMEFRAME 이 합집합에 섞이지 않도록
|
// 매수 START 분봉 우선 — 매도 쪽 레거시 1m TIMEFRAME 이 합집합에 섞이지 않도록
|
||||||
if (buyScoped && !buyOut.isEmpty()) {
|
if (buyScoped && !buyOut.isEmpty()) {
|
||||||
return buyOut;
|
return sanitizeEvaluationTimeframes(buyOut, strategy);
|
||||||
}
|
}
|
||||||
if (sellScoped && !sellOut.isEmpty()) {
|
if (sellScoped && !sellOut.isEmpty()) {
|
||||||
return sellOut;
|
return sanitizeEvaluationTimeframes(sellOut, strategy);
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<String> out = new LinkedHashSet<>();
|
Set<String> out = new LinkedHashSet<>();
|
||||||
@@ -99,9 +99,39 @@ public class StrategyConditionTimeframeService {
|
|||||||
String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName());
|
String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName());
|
||||||
return Set.of(fromName != null ? fromName : "1m");
|
return Set.of(fromName != null ? fromName : "1m");
|
||||||
}
|
}
|
||||||
|
return sanitizeEvaluationTimeframes(out, strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* START/flow 에만 남은 stale 1m 제거 — DSL 조건에 명시된 분봉 기준.
|
||||||
|
*/
|
||||||
|
private Set<String> sanitizeEvaluationTimeframes(Set<String> scope, GcStrategy strategy) {
|
||||||
|
Set<String> explicit = collectExplicitFromStrategy(strategy);
|
||||||
|
if (explicit.isEmpty()) return scope;
|
||||||
|
if (!explicit.contains("1m") && scope.contains("1m")) {
|
||||||
|
Set<String> filtered = new LinkedHashSet<>(scope);
|
||||||
|
filtered.remove("1m");
|
||||||
|
return filtered.isEmpty() ? scope : filtered;
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> collectExplicitFromStrategy(GcStrategy strategy) {
|
||||||
|
Set<String> out = new LinkedHashSet<>();
|
||||||
|
collectExplicitFromJson(strategy.getBuyConditionJson(), out);
|
||||||
|
collectExplicitFromJson(strategy.getSellConditionJson(), out);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void collectExplicitFromJson(String json, Set<String> out) {
|
||||||
|
if (json == null || json.isBlank()) return;
|
||||||
|
try {
|
||||||
|
out.addAll(StrategyDslTimeframeNormalizer.collectExplicitCandleTypes(objectMapper.readTree(json)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[StrategyTimeframes] explicit JSON 파싱 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** TIMEFRAME 누락·잘못된 1m 래핑을 전략명·DSL 기준으로 DB 보정 */
|
/** TIMEFRAME 누락·잘못된 1m 래핑을 전략명·DSL 기준으로 DB 보정 */
|
||||||
private void ensureDslRepaired(GcStrategy strategy) {
|
private void ensureDslRepaired(GcStrategy strategy) {
|
||||||
boolean changed = false;
|
boolean changed = false;
|
||||||
|
|||||||
@@ -282,6 +282,13 @@ public class StrategyDslTimeframeNormalizer {
|
|||||||
|
|
||||||
if (fromConditions.size() == 1) {
|
if (fromConditions.size() == 1) {
|
||||||
String required = fromConditions.iterator().next();
|
String required = fromConditions.iterator().next();
|
||||||
|
// START에 5m 등 명시 분봉이 있으면 조건 노드 기본값 1m 보다 우선 (편집기 START 변경 직후)
|
||||||
|
if ("1m".equals(required) && hasExplicitNon1mStart(fromStart)) {
|
||||||
|
if (fromStart.size() == 1) {
|
||||||
|
return fromStart;
|
||||||
|
}
|
||||||
|
return dropStale1mFromStart(fromStart, fromConditions);
|
||||||
|
}
|
||||||
// 레거시 [1m, X] — 조건이 X 단일 분봉이면 X만
|
// 레거시 [1m, X] — 조건이 X 단일 분봉이면 X만
|
||||||
if (fromStart.size() == 2 && fromStart.contains("1m") && fromStart.contains(required)) {
|
if (fromStart.size() == 2 && fromStart.contains("1m") && fromStart.contains(required)) {
|
||||||
return Set.of(required);
|
return Set.of(required);
|
||||||
@@ -302,11 +309,21 @@ public class StrategyDslTimeframeNormalizer {
|
|||||||
|
|
||||||
// 조건에 복수 분봉 명시 — START가 상위집합이면 START 유지 (다중 OR)
|
// 조건에 복수 분봉 명시 — START가 상위집합이면 START 유지 (다중 OR)
|
||||||
if (fromStart.containsAll(fromConditions) && fromStart.size() >= fromConditions.size()) {
|
if (fromStart.containsAll(fromConditions) && fromStart.size() >= fromConditions.size()) {
|
||||||
return fromStart;
|
return dropStale1mFromStart(fromStart, fromConditions);
|
||||||
}
|
}
|
||||||
return fromConditions;
|
return fromConditions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** START 에만 남은 stale 1m 제거 — 조건에 1m 이 없으면 평가·알림 분봉에서 제외 */
|
||||||
|
private static Set<String> dropStale1mFromStart(Set<String> fromStart, Set<String> fromConditions) {
|
||||||
|
if (!fromStart.contains("1m") || fromConditions.contains("1m")) {
|
||||||
|
return fromStart;
|
||||||
|
}
|
||||||
|
Set<String> filtered = new LinkedHashSet<>(fromStart);
|
||||||
|
filtered.remove("1m");
|
||||||
|
return filtered.isEmpty() ? fromStart : filtered;
|
||||||
|
}
|
||||||
|
|
||||||
private ObjectNode writeResolvedTimeframe(JsonNode root, String strategyName) {
|
private ObjectNode writeResolvedTimeframe(JsonNode root, String strategyName) {
|
||||||
Set<String> resolved = resolveStartCandleTypes(root);
|
Set<String> resolved = resolveStartCandleTypes(root);
|
||||||
if (resolved.isEmpty()) {
|
if (resolved.isEmpty()) {
|
||||||
@@ -333,6 +350,14 @@ public class StrategyDslTimeframeNormalizer {
|
|||||||
return fromStart.size() == 1 && fromStart.contains("1m");
|
return fromStart.size() == 1 && fromStart.contains("1m");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** START TIMEFRAME에 1m 이 아닌 분봉이 명시되어 있는지 */
|
||||||
|
private static boolean hasExplicitNon1mStart(Set<String> fromStart) {
|
||||||
|
for (String ct : fromStart) {
|
||||||
|
if (!"1m".equals(ct)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private ObjectNode reconcileStartCandleTypes(JsonNode tf) {
|
private ObjectNode reconcileStartCandleTypes(JsonNode tf) {
|
||||||
return reconcileStartCandleTypesStatic(tf);
|
return reconcileStartCandleTypesStatic(tf);
|
||||||
}
|
}
|
||||||
@@ -355,8 +380,6 @@ public class StrategyDslTimeframeNormalizer {
|
|||||||
|
|
||||||
long non1mCount = normalized.stream().filter(ct -> !"1m".equals(ct)).count();
|
long non1mCount = normalized.stream().filter(ct -> !"1m".equals(ct)).count();
|
||||||
if (non1mCount == 0) return copy;
|
if (non1mCount == 0) return copy;
|
||||||
// [1m, 3m, 5m] 등 1m+복수 상위봉 OR — 1m 의도적 선택으로 간주
|
|
||||||
if (non1mCount >= 2) return copy;
|
|
||||||
|
|
||||||
ArrayNode newArr = JsonNodeFactory.instance.arrayNode();
|
ArrayNode newArr = JsonNodeFactory.instance.arrayNode();
|
||||||
for (String ct : normalized) {
|
for (String ct : normalized) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE gc_app_settings
|
||||||
|
ADD COLUMN chart_time_format VARCHAR(64) NOT NULL DEFAULT 'MM-dd HH:mm' AFTER display_timezone;
|
||||||
+42
-1
@@ -9,6 +9,8 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.Spy;
|
import org.mockito.Spy;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.mockito.junit.jupiter.MockitoSettings;
|
||||||
|
import org.mockito.quality.Strictness;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -18,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||||
class StrategyConditionTimeframeServiceTest {
|
class StrategyConditionTimeframeServiceTest {
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
@@ -109,7 +112,11 @@ class StrategyConditionTimeframeServiceTest {
|
|||||||
"candleTypes": ["1m", "3m", "5m"],
|
"candleTypes": ["1m", "3m", "5m"],
|
||||||
"children": [{
|
"children": [{
|
||||||
"type": "CONDITION",
|
"type": "CONDITION",
|
||||||
"condition": { "indicatorType": "RSI", "period": 14 }
|
"condition": {
|
||||||
|
"indicatorType": "RSI",
|
||||||
|
"period": 14,
|
||||||
|
"leftCandleType": "1m"
|
||||||
|
}
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
""");
|
""");
|
||||||
@@ -235,6 +242,40 @@ class StrategyConditionTimeframeServiceTest {
|
|||||||
assertFalse(service.usesTimeframe(6L, "1m"));
|
assertFalse(service.usesTimeframe(6L, "1m"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void collectForStrategy_cciMultiTfDropsStale1mFromStartArray() {
|
||||||
|
GcStrategy strategy = new GcStrategy();
|
||||||
|
strategy.setId(7L);
|
||||||
|
strategy.setName("CCI | 3분 5분 10분 15분 1시간");
|
||||||
|
strategy.setBuyConditionJson("""
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleType": "1m",
|
||||||
|
"candleTypes": ["1m", "3m", "5m", "10m", "15m", "1h"],
|
||||||
|
"children": [{
|
||||||
|
"type": "AND",
|
||||||
|
"children": [
|
||||||
|
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "3m" } },
|
||||||
|
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "5m" } },
|
||||||
|
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "10m" } },
|
||||||
|
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "15m" } },
|
||||||
|
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "1h" } }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
when(strategyRepo.findById(7L)).thenReturn(Optional.of(strategy));
|
||||||
|
when(dslNormalizer.alignSellStartTimeframeToBuy(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(1));
|
||||||
|
when(dslNormalizer.normalizeJson(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Set<String> tfs = service.collectForStrategy(7L);
|
||||||
|
assertEquals(Set.of("3m", "5m", "10m", "15m", "1h"), tfs);
|
||||||
|
assertFalse(service.usesTimeframe(7L, "1m"));
|
||||||
|
assertTrue(service.usesTimeframe(7L, "3m"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void collectForStrategy_legacyTreeDefaultsTo1m() {
|
void collectForStrategy_legacyTreeDefaultsTo1m() {
|
||||||
GcStrategy legacy = new GcStrategy();
|
GcStrategy legacy = new GcStrategy();
|
||||||
|
|||||||
+55
-1
@@ -76,6 +76,31 @@ class StrategyDslTimeframeNormalizerTest {
|
|||||||
assertEquals("5m", root.path("candleType").asText());
|
assertEquals("5m", root.path("candleType").asText());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveStartCandleTypes_explicitStart5mBeatsConditionDefault1m() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleType": "5m",
|
||||||
|
"candleTypes": ["5m"],
|
||||||
|
"children": [{
|
||||||
|
"type": "CONDITION",
|
||||||
|
"condition": {
|
||||||
|
"indicatorType": "CCI",
|
||||||
|
"leftCandleType": "1m",
|
||||||
|
"rightCandleType": "1m"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
JsonNode root = objectMapper.readTree(json);
|
||||||
|
assertEquals(Set.of("5m"), StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root));
|
||||||
|
String normalized = normalizer.normalizeJson(json, "cci_5분봉");
|
||||||
|
JsonNode out = objectMapper.readTree(normalized);
|
||||||
|
assertEquals("5m", out.path("candleType").asText());
|
||||||
|
assertEquals("5m", out.path("candleTypes").get(0).asText());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void resolveStartCandleTypes_meaningfulOverridesStaleStart1m() throws Exception {
|
void resolveStartCandleTypes_meaningfulOverridesStaleStart1m() throws Exception {
|
||||||
String json = """
|
String json = """
|
||||||
@@ -161,6 +186,31 @@ class StrategyDslTimeframeNormalizerTest {
|
|||||||
assertEquals("15m", root.path("candleType").asText());
|
assertEquals("15m", root.path("candleType").asText());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveStartCandleTypes_dropsStale1mWhenConditionsUseMultipleUpperTfs() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleType": "1m",
|
||||||
|
"candleTypes": ["1m", "3m", "5m", "10m", "15m", "1h"],
|
||||||
|
"children": [{
|
||||||
|
"type": "AND",
|
||||||
|
"children": [
|
||||||
|
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "3m" } },
|
||||||
|
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "5m" } },
|
||||||
|
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "10m" } },
|
||||||
|
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "15m" } },
|
||||||
|
{ "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "1h" } }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
JsonNode root = objectMapper.readTree(json);
|
||||||
|
assertEquals(
|
||||||
|
Set.of("3m", "5m", "10m", "15m", "1h"),
|
||||||
|
StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void normalize_preservesMultiCandleTypesOnSave() throws Exception {
|
void normalize_preservesMultiCandleTypesOnSave() throws Exception {
|
||||||
String json = """
|
String json = """
|
||||||
@@ -170,7 +220,11 @@ class StrategyDslTimeframeNormalizerTest {
|
|||||||
"candleTypes": ["1m", "3m", "5m"],
|
"candleTypes": ["1m", "3m", "5m"],
|
||||||
"children": [{
|
"children": [{
|
||||||
"type": "CONDITION",
|
"type": "CONDITION",
|
||||||
"condition": { "indicatorType": "RSI", "period": 14 }
|
"condition": {
|
||||||
|
"indicatorType": "RSI",
|
||||||
|
"period": 14,
|
||||||
|
"leftCandleType": "1m"
|
||||||
|
}
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|||||||
@@ -899,6 +899,28 @@ html.theme-blue {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 캔들 pane 하단 시간축 (거래량·보조지표가 아래에 있을 때) */
|
||||||
|
.candle-pane-time-axis {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
z-index: 12;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
border-top: 1px solid var(--border, rgba(42, 46, 58, 0.85));
|
||||||
|
background: color-mix(in srgb, var(--bg, #131722) 88%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.candle-pane-time-axis__label {
|
||||||
|
position: absolute;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text2, #787b86);
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Loading overlay */
|
/* Loading overlay */
|
||||||
/* 과거 데이터 추가 로드 중 표시 (차트 좌상단 소형 배지) */
|
/* 과거 데이터 추가 로드 중 표시 (차트 좌상단 소형 배지) */
|
||||||
.chart-history-loading {
|
.chart-history-loading {
|
||||||
@@ -9376,6 +9398,21 @@ html.theme-blue {
|
|||||||
.stg-badge--off { background: rgba(158,158,158,0.2); color: var(--text2); margin-left: 8px; }
|
.stg-badge--off { background: rgba(158,158,158,0.2); color: var(--text2); margin-left: 8px; }
|
||||||
.stg-hint { margin: 0 0 8px; padding: 0 4px; font-size: 12px; color: var(--text2); line-height: 1.5; }
|
.stg-hint { margin: 0 0 8px; padding: 0 4px; font-size: 12px; color: var(--text2); line-height: 1.5; }
|
||||||
|
|
||||||
|
.stg-chart-time-format {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.stg-chart-time-format-custom {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
.stg-chart-time-format-preview {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 연결 테스트 버튼 ── */
|
/* ── 연결 테스트 버튼 ── */
|
||||||
.stg-btn-test {
|
.stg-btn-test {
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
|
|||||||
+31
-6
@@ -16,6 +16,11 @@ import FibTZSettingsModal from './components/FibTZSettingsModal';
|
|||||||
import ChartSlot, { type ChartSlotHandle } from './components/ChartSlot';
|
import ChartSlot, { type ChartSlotHandle } from './components/ChartSlot';
|
||||||
import { LAYOUTS, DEFAULT_SYNC, type LayoutDef, type SyncOptions } from './utils/layoutTypes';
|
import { LAYOUTS, DEFAULT_SYNC, type LayoutDef, type SyncOptions } from './utils/layoutTypes';
|
||||||
import { generateBars, formatPrice, SYMBOLS } from './utils/dataGenerator';
|
import { generateBars, formatPrice, SYMBOLS } from './utils/dataGenerator';
|
||||||
|
import {
|
||||||
|
normalizeChartTimeFormat,
|
||||||
|
setChartTimeFormat,
|
||||||
|
useChartTimeFormat,
|
||||||
|
} from './utils/chartTimeFormat';
|
||||||
import { normalizeTimezone, setDisplayTimezone, useDisplayTimezone } from './utils/timezone';
|
import { normalizeTimezone, setDisplayTimezone, useDisplayTimezone } from './utils/timezone';
|
||||||
import { calcStats, type PriceStats } from './utils/calculations';
|
import { calcStats, type PriceStats } from './utils/calculations';
|
||||||
import { getKoreanName } from './utils/marketNameCache';
|
import { getKoreanName } from './utils/marketNameCache';
|
||||||
@@ -47,6 +52,10 @@ import { useHistoryLoader, LOAD_MORE_TRIGGER } from './hooks/useHistoryLoader';
|
|||||||
import { useMarketTicker } from './hooks/useMarketTicker';
|
import { useMarketTicker } from './hooks/useMarketTicker';
|
||||||
import { useUpbitOrderbook } from './hooks/useUpbitOrderbook';
|
import { useUpbitOrderbook } from './hooks/useUpbitOrderbook';
|
||||||
import { useIndicatorSettings } from './hooks/useIndicatorSettings';
|
import { useIndicatorSettings } from './hooks/useIndicatorSettings';
|
||||||
|
import {
|
||||||
|
consumeSkipNextChartDefaultsSync,
|
||||||
|
markChartIndicatorSaveCommitted,
|
||||||
|
} from './utils/indicatorChartDefaultsSync';
|
||||||
import {
|
import {
|
||||||
duplicateIndicatorInList,
|
duplicateIndicatorInList,
|
||||||
mergeIndicators,
|
mergeIndicators,
|
||||||
@@ -182,6 +191,7 @@ function App() {
|
|||||||
const { defaults: appDefaults, isLoaded: appSettingsLoaded, save: saveAppDef } = useAppSettings(sessionKey);
|
const { defaults: appDefaults, isLoaded: appSettingsLoaded, save: saveAppDef } = useAppSettings(sessionKey);
|
||||||
const { permissions: menuPermissions, isLoaded: menuPermsLoaded, can: canMenu } = useMenuPermissions(sessionKey);
|
const { permissions: menuPermissions, isLoaded: menuPermsLoaded, can: canMenu } = useMenuPermissions(sessionKey);
|
||||||
const displayTimezone = useDisplayTimezone();
|
const displayTimezone = useDisplayTimezone();
|
||||||
|
const chartTimeFormat = useChartTimeFormat();
|
||||||
|
|
||||||
// DB 로드 전에는 localStorage 기본값, 로드 후 DB 값으로 오버라이드됨
|
// DB 로드 전에는 localStorage 기본값, 로드 후 DB 값으로 오버라이드됨
|
||||||
const [symbol, setSymbol] = useState(initial.symbol);
|
const [symbol, setSymbol] = useState(initial.symbol);
|
||||||
@@ -253,8 +263,16 @@ function App() {
|
|||||||
setDisplayTimezone(next);
|
setDisplayTimezone(next);
|
||||||
saveAppDef({ displayTimezone: next });
|
saveAppDef({ displayTimezone: next });
|
||||||
const tf = layoutDef.count > 1 ? activeSlotTf : timeframe;
|
const tf = layoutDef.count > 1 ? activeSlotTf : timeframe;
|
||||||
managerRef.current?.setDisplayTimezone(next, tf);
|
managerRef.current?.setDisplayTimezone(next, tf, chartTimeFormat);
|
||||||
}, [saveAppDef, layoutDef.count, activeSlotTf, timeframe]);
|
}, [saveAppDef, layoutDef.count, activeSlotTf, timeframe, chartTimeFormat]);
|
||||||
|
|
||||||
|
const handleChartTimeFormatChange = useCallback((format: string) => {
|
||||||
|
const next = normalizeChartTimeFormat(format);
|
||||||
|
setChartTimeFormat(next);
|
||||||
|
saveAppDef({ chartTimeFormat: next });
|
||||||
|
const tf = layoutDef.count > 1 ? activeSlotTf : timeframe;
|
||||||
|
managerRef.current?.setDisplayTimezone(displayTimezone, tf, next);
|
||||||
|
}, [saveAppDef, layoutDef.count, activeSlotTf, timeframe, displayTimezone]);
|
||||||
|
|
||||||
const chartSymbol = layoutDef.count > 1 ? activeSlotSymbol : symbol;
|
const chartSymbol = layoutDef.count > 1 ? activeSlotSymbol : symbol;
|
||||||
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol);
|
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol);
|
||||||
@@ -957,8 +975,8 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appSettingsLoaded) return;
|
if (!appSettingsLoaded) return;
|
||||||
const tf = layoutDef.count > 1 ? activeSlotTf : timeframe;
|
const tf = layoutDef.count > 1 ? activeSlotTf : timeframe;
|
||||||
managerRef.current?.setDisplayTimezone(displayTimezone, tf);
|
managerRef.current?.setDisplayTimezone(displayTimezone, tf, chartTimeFormat);
|
||||||
}, [appSettingsLoaded, displayTimezone, timeframe, activeSlotTf, layoutDef.count]);
|
}, [appSettingsLoaded, displayTimezone, chartTimeFormat, timeframe, activeSlotTf, layoutDef.count]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appSettingsLoaded) return;
|
if (!appSettingsLoaded) return;
|
||||||
@@ -1484,6 +1502,7 @@ function App() {
|
|||||||
const fromTemplate =
|
const fromTemplate =
|
||||||
isIndicatorSettingsTemplateId(settingsModalId ?? '')
|
isIndicatorSettingsTemplateId(settingsModalId ?? '')
|
||||||
|| updated.id.startsWith('template_');
|
|| updated.id.startsWith('template_');
|
||||||
|
markChartIndicatorSaveCommitted();
|
||||||
saveParams(updated.type, updated.params);
|
saveParams(updated.type, updated.params);
|
||||||
saveVisual(
|
saveVisual(
|
||||||
updated.type,
|
updated.type,
|
||||||
@@ -1554,6 +1573,10 @@ function App() {
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!chartVisible) return;
|
if (!chartVisible) return;
|
||||||
if (indicatorSettingsRevision <= lastSyncedSettingsRevisionRef.current) return;
|
if (indicatorSettingsRevision <= lastSyncedSettingsRevisionRef.current) return;
|
||||||
|
if (consumeSkipNextChartDefaultsSync()) {
|
||||||
|
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
|
||||||
|
return;
|
||||||
|
}
|
||||||
syncChartsFromSavedDefaults();
|
syncChartsFromSavedDefaults();
|
||||||
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
|
lastSyncedSettingsRevisionRef.current = indicatorSettingsRevision;
|
||||||
}, [chartVisible, indicatorSettingsRevision, syncChartsFromSavedDefaults]);
|
}, [chartVisible, indicatorSettingsRevision, syncChartsFromSavedDefaults]);
|
||||||
@@ -1756,7 +1779,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 설정 화면 ──────────────────────────────────────────────────── */}
|
{/* ── 설정 화면 ──────────────────────────────────────────────────── */}
|
||||||
{menuPage === 'notifications' && (
|
{menuPage === 'notifications' && canMenu('notifications') && (
|
||||||
<TradeNotificationListPage
|
<TradeNotificationListPage
|
||||||
theme={theme}
|
theme={theme}
|
||||||
tickers={marketTickers}
|
tickers={marketTickers}
|
||||||
@@ -1885,6 +1908,8 @@ function App() {
|
|||||||
onFcmPushEnabled={v => saveAppDef({ fcmPushEnabled: v })}
|
onFcmPushEnabled={v => saveAppDef({ fcmPushEnabled: v })}
|
||||||
displayTimezone={displayTimezone}
|
displayTimezone={displayTimezone}
|
||||||
onDisplayTimezoneChange={handleTimezoneChange}
|
onDisplayTimezoneChange={handleTimezoneChange}
|
||||||
|
chartTimeFormat={chartTimeFormat}
|
||||||
|
onChartTimeFormatChange={handleChartTimeFormatChange}
|
||||||
verificationIssueNotify={appDefaults.verificationIssueNotify}
|
verificationIssueNotify={appDefaults.verificationIssueNotify}
|
||||||
onVerificationIssueNotify={v => saveAppDef({ verificationIssueNotify: v })}
|
onVerificationIssueNotify={v => saveAppDef({ verificationIssueNotify: v })}
|
||||||
onFcmTest={async () => {
|
onFcmTest={async () => {
|
||||||
@@ -2256,7 +2281,7 @@ function App() {
|
|||||||
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
|
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
|
||||||
mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight);
|
mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight);
|
||||||
mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
||||||
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
|
||||||
syncBacktestMarkersRef.current();
|
syncBacktestMarkersRef.current();
|
||||||
// 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
|
// 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
|
||||||
mgr.subscribeVisibleLogicalRange(r => {
|
mgr.subscribeVisibleLogicalRange(r => {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 캔들 pane 하단 시간축 — 거래량·보조지표가 아래에 있을 때 캔들 영역 바로 아래에도 시간 표시.
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import type { ChartManager } from '../utils/ChartManager';
|
||||||
|
|
||||||
|
const AXIS_HEIGHT = 22;
|
||||||
|
|
||||||
|
interface CandlePaneTimeAxisProps {
|
||||||
|
manager: ChartManager;
|
||||||
|
containerEl: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CandlePaneTimeAxis: React.FC<CandlePaneTimeAxisProps> = ({ manager, containerEl }) => {
|
||||||
|
const [state, setState] = useState<ReturnType<ChartManager['getCandlePaneTimeAxisOverlay']>>(null);
|
||||||
|
|
||||||
|
const sync = useCallback(() => {
|
||||||
|
if (!containerEl.isConnected) return;
|
||||||
|
setState(manager.getCandlePaneTimeAxisOverlay());
|
||||||
|
}, [manager, containerEl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sync();
|
||||||
|
const ro = new ResizeObserver(sync);
|
||||||
|
ro.observe(containerEl);
|
||||||
|
|
||||||
|
const unsubLayout = manager.subscribePaneLayout(sync);
|
||||||
|
const unsubRange = manager.subscribeViewport(sync);
|
||||||
|
|
||||||
|
const timers = [0, 50, 150, 400].map(ms => window.setTimeout(sync, ms));
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
unsubLayout();
|
||||||
|
unsubRange();
|
||||||
|
timers.forEach(clearTimeout);
|
||||||
|
};
|
||||||
|
}, [manager, containerEl, sync]);
|
||||||
|
|
||||||
|
if (!state || state.labels.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="candle-pane-time-axis"
|
||||||
|
style={{
|
||||||
|
top: state.topY,
|
||||||
|
height: state.height ?? AXIS_HEIGHT,
|
||||||
|
width: state.plotWidth,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{state.labels.map((label, idx) => (
|
||||||
|
<span
|
||||||
|
key={`${label.x}-${idx}`}
|
||||||
|
className="candle-pane-time-axis__label"
|
||||||
|
style={{ left: label.x }}
|
||||||
|
>
|
||||||
|
{label.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CandlePaneTimeAxis;
|
||||||
@@ -27,6 +27,7 @@ import { invalidateMarketCache } from '../utils/requestCache';
|
|||||||
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
||||||
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
|
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
|
||||||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||||
|
import { markChartIndicatorSaveCommitted } from '../utils/indicatorChartDefaultsSync';
|
||||||
import {
|
import {
|
||||||
duplicateIndicatorInList,
|
duplicateIndicatorInList,
|
||||||
mergeIndicators,
|
mergeIndicators,
|
||||||
@@ -568,6 +569,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i));
|
setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i));
|
||||||
setSettingsModalId(null);
|
setSettingsModalId(null);
|
||||||
setCtxToolbar(null);
|
setCtxToolbar(null);
|
||||||
|
markChartIndicatorSaveCommitted();
|
||||||
// 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영
|
// 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영
|
||||||
saveParams(updated.type, updated.params);
|
saveParams(updated.type, updated.params);
|
||||||
// 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용
|
// 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
CHART_TIME_FORMAT_PRESETS,
|
||||||
|
formatUnixWithChartPattern,
|
||||||
|
isPresetChartTimeFormat,
|
||||||
|
normalizeChartTimeFormat,
|
||||||
|
} from '../utils/chartTimeFormat';
|
||||||
|
|
||||||
|
const CUSTOM_VALUE = '__custom__';
|
||||||
|
|
||||||
|
interface ChartTimeFormatPickerProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (format: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTimeFormatPicker: React.FC<ChartTimeFormatPickerProps> = ({ value, onChange }) => {
|
||||||
|
const normalized = normalizeChartTimeFormat(value);
|
||||||
|
const isPreset = isPresetChartTimeFormat(normalized);
|
||||||
|
|
||||||
|
const [selectValue, setSelectValue] = useState(
|
||||||
|
isPreset ? normalized : CUSTOM_VALUE,
|
||||||
|
);
|
||||||
|
const [customText, setCustomText] = useState(
|
||||||
|
isPreset ? '' : normalized,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const n = normalizeChartTimeFormat(value);
|
||||||
|
if (isPresetChartTimeFormat(n)) {
|
||||||
|
setSelectValue(n);
|
||||||
|
setCustomText('');
|
||||||
|
} else {
|
||||||
|
setSelectValue(CUSTOM_VALUE);
|
||||||
|
setCustomText(n);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
const fmt = selectValue === CUSTOM_VALUE
|
||||||
|
? normalizeChartTimeFormat(customText)
|
||||||
|
: selectValue;
|
||||||
|
return formatUnixWithChartPattern(Math.floor(Date.now() / 1000), fmt);
|
||||||
|
}, [selectValue, customText]);
|
||||||
|
|
||||||
|
const commitCustom = useCallback(() => {
|
||||||
|
const next = normalizeChartTimeFormat(customText);
|
||||||
|
onChange(next);
|
||||||
|
}, [customText, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stg-chart-time-format">
|
||||||
|
<select
|
||||||
|
className="stg-select"
|
||||||
|
value={selectValue}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setSelectValue(v);
|
||||||
|
if (v !== CUSTOM_VALUE) {
|
||||||
|
onChange(v);
|
||||||
|
} else if (customText.trim()) {
|
||||||
|
onChange(normalizeChartTimeFormat(customText));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CHART_TIME_FORMAT_PRESETS.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.label}</option>
|
||||||
|
))}
|
||||||
|
<option value={CUSTOM_VALUE}>직접 입력…</option>
|
||||||
|
</select>
|
||||||
|
{selectValue === CUSTOM_VALUE && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="stg-input stg-chart-time-format-custom"
|
||||||
|
placeholder="예: yyyy-MM-dd HH:mm"
|
||||||
|
value={customText}
|
||||||
|
onChange={e => setCustomText(e.target.value)}
|
||||||
|
onBlur={commitCustom}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') commitCustom(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="stg-hint stg-chart-time-format-preview" title="현재 시각 미리보기">
|
||||||
|
미리보기: {preview}
|
||||||
|
</span>
|
||||||
|
<span className="stg-hint">
|
||||||
|
토큰: yyyy, yy, MM, dd, HH, mm, ss
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChartTimeFormatPicker;
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
LINE_WIDTH_OPTIONS,
|
LINE_WIDTH_OPTIONS,
|
||||||
} from '../utils/plotColorUtils';
|
} from '../utils/plotColorUtils';
|
||||||
import TimezonePicker from './TimezonePicker';
|
import TimezonePicker from './TimezonePicker';
|
||||||
|
import ChartTimeFormatPicker from './ChartTimeFormatPicker';
|
||||||
import AdminPasswordGate from './AdminPasswordGate';
|
import AdminPasswordGate from './AdminPasswordGate';
|
||||||
import AdminSettingsPanel from './AdminSettingsPanel';
|
import AdminSettingsPanel from './AdminSettingsPanel';
|
||||||
import {
|
import {
|
||||||
@@ -152,6 +153,8 @@ interface SettingsPageProps {
|
|||||||
onFcmTest?: () => void;
|
onFcmTest?: () => void;
|
||||||
displayTimezone?: string;
|
displayTimezone?: string;
|
||||||
onDisplayTimezoneChange?: (tz: string) => void;
|
onDisplayTimezoneChange?: (tz: string) => void;
|
||||||
|
chartTimeFormat?: string;
|
||||||
|
onChartTimeFormatChange?: (format: string) => void;
|
||||||
menuPermissions?: Record<string, boolean>;
|
menuPermissions?: Record<string, boolean>;
|
||||||
verificationIssueNotify?: boolean;
|
verificationIssueNotify?: boolean;
|
||||||
onVerificationIssueNotify?: (v: boolean) => void;
|
onVerificationIssueNotify?: (v: boolean) => void;
|
||||||
@@ -651,6 +654,8 @@ interface ChartPanelProps {
|
|||||||
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
||||||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||||||
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
|
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
|
||||||
|
chartTimeFormat?: string;
|
||||||
|
onChartTimeFormatChange?: (format: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChartPanel: React.FC<ChartPanelProps> = ({
|
const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||||
@@ -667,6 +672,8 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
|||||||
onChartLegendOptionsChange,
|
onChartLegendOptionsChange,
|
||||||
chartPaneSeparator,
|
chartPaneSeparator,
|
||||||
onChartPaneSeparatorChange,
|
onChartPaneSeparatorChange,
|
||||||
|
chartTimeFormat = 'MM-dd HH:mm',
|
||||||
|
onChartTimeFormatChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [upColor, setUp] = useState('#26a69a');
|
const [upColor, setUp] = useState('#26a69a');
|
||||||
const [downColor, setDown] = useState('#ef5350');
|
const [downColor, setDown] = useState('#ef5350');
|
||||||
@@ -678,6 +685,18 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<SettingSection title="시간축 표시">
|
||||||
|
<SettingRow
|
||||||
|
label="시간 포맷"
|
||||||
|
desc="차트 하단 시간축·크로스헤어에 표시되는 날짜·시간 형식입니다. 프리셋을 선택하거나 직접 입력할 수 있습니다."
|
||||||
|
>
|
||||||
|
<ChartTimeFormatPicker
|
||||||
|
value={chartTimeFormat}
|
||||||
|
onChange={fmt => onChartTimeFormatChange?.(fmt)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
<SettingSection title="실시간 차트 데이터">
|
<SettingSection title="실시간 차트 데이터">
|
||||||
<SettingRow label="데이터 소스" desc="Blueprint 권장: 백엔드 STOMP. 직연결은 업비트 WebSocket을 프론트가 직접 수신합니다.">
|
<SettingRow label="데이터 소스" desc="Blueprint 권장: 백엔드 STOMP. 직연결은 업비트 WebSocket을 프론트가 직접 수신합니다.">
|
||||||
<select className="stg-select" value={chartRealtimeSource}
|
<select className="stg-select" value={chartRealtimeSource}
|
||||||
@@ -1577,6 +1596,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
|||||||
onFcmTest,
|
onFcmTest,
|
||||||
displayTimezone = 'Asia/Seoul',
|
displayTimezone = 'Asia/Seoul',
|
||||||
onDisplayTimezoneChange,
|
onDisplayTimezoneChange,
|
||||||
|
chartTimeFormat = 'MM-dd HH:mm',
|
||||||
|
onChartTimeFormatChange,
|
||||||
menuPermissions,
|
menuPermissions,
|
||||||
verificationIssueNotify = true,
|
verificationIssueNotify = true,
|
||||||
onVerificationIssueNotify,
|
onVerificationIssueNotify,
|
||||||
@@ -1626,6 +1647,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
|||||||
onChartLegendOptionsChange={onChartLegendOptionsChange}
|
onChartLegendOptionsChange={onChartLegendOptionsChange}
|
||||||
chartPaneSeparator={chartPaneSeparator}
|
chartPaneSeparator={chartPaneSeparator}
|
||||||
onChartPaneSeparatorChange={onChartPaneSeparatorChange}
|
onChartPaneSeparatorChange={onChartPaneSeparatorChange}
|
||||||
|
chartTimeFormat={chartTimeFormat}
|
||||||
|
onChartTimeFormatChange={onChartTimeFormatChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'indicators':
|
case 'indicators':
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
|||||||
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
|
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
|
||||||
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
|
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
|
||||||
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
|
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
|
||||||
|
{ page: 'notifications', label: '알림목록', icon: <IcNotify /> },
|
||||||
{ page: 'settings', label: '설정', icon: <IcSettings /> },
|
{ page: 'settings', label: '설정', icon: <IcSettings /> },
|
||||||
{ page: 'verification-board', label: '검증게시판', icon: <IcVerificationBoard /> },
|
{ page: 'verification-board', label: '검증게시판', icon: <IcVerificationBoard /> },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { OrderbookPanel } from './OrderbookPanel';
|
|||||||
import PaperSplitPanel from './paper/PaperSplitPanel';
|
import PaperSplitPanel from './paper/PaperSplitPanel';
|
||||||
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
||||||
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
|
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
|
||||||
|
import { useAppSettings } from '../hooks/useAppSettings';
|
||||||
|
import '../styles/strategyEditorTheme.css';
|
||||||
import '../styles/virtualTradingDashboard.css';
|
import '../styles/virtualTradingDashboard.css';
|
||||||
import '../styles/tradeNotificationList.css';
|
import '../styles/tradeNotificationList.css';
|
||||||
import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow';
|
import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow';
|
||||||
@@ -95,6 +97,8 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { settings: appSettings } = useAppSettings();
|
||||||
|
const chartLiveReceiveHighlight = appSettings.chartLiveReceiveHighlight ?? true;
|
||||||
|
|
||||||
const refreshPaperData = useCallback(async () => {
|
const refreshPaperData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -205,43 +209,16 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
|
const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`tnl-page tnl-page--with-right bps-page--vtd app ${theme}`}>
|
<div className={`tnl-page tnl-page--with-right bps-page--vtd se-page se-page--${theme} app ${theme}`}>
|
||||||
<div className="tnl-body">
|
<div className="tnl-body">
|
||||||
<div className="tnl-main" style={{ containerType: 'inline-size', containerName: 'tnl-main' }}>
|
<div className="tnl-main" style={{ containerType: 'inline-size', containerName: 'tnl-main' }}>
|
||||||
<div className="tnl-header">
|
<div className="tnl-header">
|
||||||
<div className="tnl-header-row">
|
<h1 className="tnl-title">매매 시그널 알림</h1>
|
||||||
<div className="tnl-header-intro">
|
<p className="tnl-sub">
|
||||||
<h1 className="tnl-title">매매 시그널 알림</h1>
|
{isListView
|
||||||
<p className="tnl-sub">
|
? '각 알림 왼쪽은 가상매매와 동일한 신호 상세 카드, 오른쪽은 캔들·지표 차트입니다. 영역 우측 버튼으로 가로 스크롤할 수 있습니다.'
|
||||||
{isListView
|
: `목록형과 동일한 알림 상세 카드를 ${getTradeNotificationGridPreset(gridPreset).label} 그리드로 표시합니다. 툴바 우측에서 배치를 변경할 수 있습니다.`}
|
||||||
? '각 알림 왼쪽은 요약, 오른쪽은 캔들·지표 차트입니다. 차트 영역은 가로 스크롤로 확인할 수 있습니다.'
|
</p>
|
||||||
: `목록형과 동일한 알림 상세 카드를 ${getTradeNotificationGridPreset(gridPreset).label} 그리드로 표시합니다. 우측 아이콘으로 배치를 변경할 수 있습니다.`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="tnl-header-actions">
|
|
||||||
<div className="tnl-layout-toggle vtd-view-toggle" role="group" aria-label="목록 표시 방식">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`vtd-view-toggle-btn${isListView ? ' vtd-view-toggle-btn--on' : ''}`}
|
|
||||||
onClick={() => handleListLayoutChange('list')}
|
|
||||||
>
|
|
||||||
목록형
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`vtd-view-toggle-btn${!isListView ? ' vtd-view-toggle-btn--on' : ''}`}
|
|
||||||
onClick={() => handleListLayoutChange('grid')}
|
|
||||||
>
|
|
||||||
그리드형
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<TradeNotificationGridLayoutPicker
|
|
||||||
value={gridPreset}
|
|
||||||
onChange={handleGridPresetChange}
|
|
||||||
disabled={isListView}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="tnl-toolbar">
|
<div className="tnl-toolbar">
|
||||||
<span className="tnl-chip tnl-chip--warn">미확인 {unreadCount}건</span>
|
<span className="tnl-chip tnl-chip--warn">미확인 {unreadCount}건</span>
|
||||||
<div className="tnl-filter">
|
<div className="tnl-filter">
|
||||||
@@ -268,27 +245,50 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
모두 읽음
|
모두 읽음
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="tnl-toolbar-divider" aria-hidden="true" />
|
<div className="tnl-toolbar-end">
|
||||||
<button
|
<div className="tnl-layout-toggle vtd-view-toggle" role="group" aria-label="목록 표시 방식">
|
||||||
type="button"
|
<button
|
||||||
className="tnl-icon-btn"
|
type="button"
|
||||||
title="미확인 알림 삭제"
|
className={`vtd-view-toggle-btn${isListView ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||||
aria-label="미확인 알림 삭제"
|
onClick={() => handleListLayoutChange('list')}
|
||||||
disabled={unreadCount === 0}
|
>
|
||||||
onClick={() => void handleDeleteUnread()}
|
목록형
|
||||||
>
|
</button>
|
||||||
<IcTrash />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
className={`vtd-view-toggle-btn${!isListView ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||||
type="button"
|
onClick={() => handleListLayoutChange('grid')}
|
||||||
className="tnl-icon-btn tnl-icon-btn--danger"
|
>
|
||||||
title="모두 삭제"
|
그리드형
|
||||||
aria-label="모두 삭제"
|
</button>
|
||||||
disabled={allNotifications.length === 0}
|
</div>
|
||||||
onClick={() => void handleDeleteAll()}
|
<TradeNotificationGridLayoutPicker
|
||||||
>
|
value={gridPreset}
|
||||||
<IcTrash />
|
onChange={handleGridPresetChange}
|
||||||
</button>
|
disabled={isListView}
|
||||||
|
/>
|
||||||
|
<span className="tnl-toolbar-divider" aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tnl-icon-btn"
|
||||||
|
title="미확인 알림 삭제"
|
||||||
|
aria-label="미확인 알림 삭제"
|
||||||
|
disabled={unreadCount === 0}
|
||||||
|
onClick={() => void handleDeleteUnread()}
|
||||||
|
>
|
||||||
|
<IcTrash />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tnl-icon-btn tnl-icon-btn--danger"
|
||||||
|
title="모두 삭제"
|
||||||
|
aria-label="모두 삭제"
|
||||||
|
disabled={allNotifications.length === 0}
|
||||||
|
onClick={() => void handleDeleteAll()}
|
||||||
|
>
|
||||||
|
<IcTrash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -312,6 +312,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
layoutMode={listLayout}
|
layoutMode={listLayout}
|
||||||
isSelected={selectedNotifyId === item.id}
|
isSelected={selectedNotifyId === item.id}
|
||||||
chartsEnabled={isListView}
|
chartsEnabled={isListView}
|
||||||
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
onSelect={() => handleNotificationSelect(item)}
|
onSelect={() => handleNotificationSelect(item)}
|
||||||
onDelete={e => void handleDeleteOne(item, e)}
|
onDelete={e => void handleDeleteOne(item, e)}
|
||||||
onDetail={() => {
|
onDetail={() => {
|
||||||
@@ -322,6 +323,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
markAsRead(item.id);
|
markAsRead(item.id);
|
||||||
onGoToChart(item.market);
|
onGoToChart(item.market);
|
||||||
}}
|
}}
|
||||||
|
tickers={tickers}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from
|
|||||||
import type { MouseEventParams, Time } from 'lightweight-charts';
|
import type { MouseEventParams, Time } from 'lightweight-charts';
|
||||||
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types';
|
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types';
|
||||||
import { ChartManager } from '../utils/ChartManager';
|
import { ChartManager } from '../utils/ChartManager';
|
||||||
|
import { useChartTimeFormat } from '../utils/chartTimeFormat';
|
||||||
import { setIndicatorChartContext } from '../utils/indicatorRegistry';
|
import { setIndicatorChartContext } from '../utils/indicatorRegistry';
|
||||||
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ function canApplyChartBars(
|
|||||||
}
|
}
|
||||||
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
|
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
|
||||||
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
|
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
|
||||||
|
import CandlePaneTimeAxis from './CandlePaneTimeAxis';
|
||||||
import ChartHoverToolbar from './ChartHoverToolbar';
|
import ChartHoverToolbar from './ChartHoverToolbar';
|
||||||
import ChartMagnifier from './ChartMagnifier';
|
import ChartMagnifier from './ChartMagnifier';
|
||||||
import ChartContextMenu from './ChartContextMenu';
|
import ChartContextMenu from './ChartContextMenu';
|
||||||
@@ -216,6 +218,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
|
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
|
||||||
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
|
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
|
||||||
const indicatorSyncInFlightRef = useRef(false);
|
const indicatorSyncInFlightRef = useRef(false);
|
||||||
|
const pendingIndicatorResyncRef = useRef(false);
|
||||||
const indicatorReloadGenRef = useRef(0);
|
const indicatorReloadGenRef = useRef(0);
|
||||||
const prevMarket = useRef<string>(market);
|
const prevMarket = useRef<string>(market);
|
||||||
const prevMarketTf = useRef<Timeframe>(timeframe);
|
const prevMarketTf = useRef<Timeframe>(timeframe);
|
||||||
@@ -228,6 +231,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const resizeDebounceRef = useRef<number | null>(null);
|
const resizeDebounceRef = useRef<number | null>(null);
|
||||||
const chartVisibleRef = useRef(chartVisible);
|
const chartVisibleRef = useRef(chartVisible);
|
||||||
|
|
||||||
|
const chartTimeFormat = useChartTimeFormat();
|
||||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||||
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
||||||
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
|
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
|
||||||
@@ -456,6 +460,27 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
});
|
});
|
||||||
}, [afterIndicatorPaneMutation, restoreLogicalRange]);
|
}, [afterIndicatorPaneMutation, restoreLogicalRange]);
|
||||||
|
|
||||||
|
/** 설정 저장 등으로 연속 갱신이 겹칠 때 마지막 상태로 한 번 더 동기화 */
|
||||||
|
const flushPendingIndicatorResync = useCallback((
|
||||||
|
mgr: ChartManager,
|
||||||
|
completedGen: number,
|
||||||
|
) => {
|
||||||
|
if (!pendingIndicatorResyncRef.current) return;
|
||||||
|
if (completedGen !== indicatorReloadGenRef.current) return;
|
||||||
|
pendingIndicatorResyncRef.current = false;
|
||||||
|
const latest = indicatorsRef.current;
|
||||||
|
indicatorSyncInFlightRef.current = true;
|
||||||
|
const flushGen = ++indicatorReloadGenRef.current;
|
||||||
|
reloadIndicatorsWithCover(mgr, latest, () => {
|
||||||
|
if (flushGen !== indicatorReloadGenRef.current) {
|
||||||
|
indicatorSyncInFlightRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncIndicatorTrackingRefs(latest);
|
||||||
|
indicatorSyncInFlightRef.current = false;
|
||||||
|
});
|
||||||
|
}, [reloadIndicatorsWithCover, syncIndicatorTrackingRefs]);
|
||||||
|
|
||||||
const queueFullReload = useCallback(() => {
|
const queueFullReload = useCallback(() => {
|
||||||
if (!chartVisibleRef.current) return;
|
if (!chartVisibleRef.current) return;
|
||||||
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
||||||
@@ -573,7 +598,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
}
|
}
|
||||||
mgr.setTheme(th);
|
mgr.setTheme(th);
|
||||||
mgr.setLogScale(ls);
|
mgr.setLogScale(ls);
|
||||||
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
|
||||||
commitCandleLayout(mgr);
|
commitCandleLayout(mgr);
|
||||||
onCandlesReady?.();
|
onCandlesReady?.();
|
||||||
lastAppliedMarketRef.current = market;
|
lastAppliedMarketRef.current = market;
|
||||||
@@ -996,8 +1021,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe);
|
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
|
||||||
}, [displayTimezone, timeframe]);
|
}, [displayTimezone, timeframe, chartTimeFormat]);
|
||||||
|
|
||||||
// 종목·타임프레임 변경 시 barsKey 캐시만 초기화 (reloadAll 은 bars effect 에서 1회만)
|
// 종목·타임프레임 변경 시 barsKey 캐시만 초기화 (reloadAll 은 bars effect 에서 1회만)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1083,6 +1108,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
prevLogScale.current = logScale;
|
prevLogScale.current = logScale;
|
||||||
mgr.setLogScale(logScale);
|
mgr.setLogScale(logScale);
|
||||||
} else if (indChanged) {
|
} else if (indChanged) {
|
||||||
|
if (indicatorSyncInFlightRef.current) {
|
||||||
|
pendingIndicatorResyncRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const prevList = prevIndicatorsListRef.current;
|
const prevList = prevIndicatorsListRef.current;
|
||||||
const prevPK = prevIndKey.current.split('@@')[0] ?? '';
|
const prevPK = prevIndKey.current.split('@@')[0] ?? '';
|
||||||
const currPK = paramKey(indicators);
|
const currPK = paramKey(indicators);
|
||||||
@@ -1142,12 +1171,18 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
&& addedConfigs.length === 0
|
&& addedConfigs.length === 0
|
||||||
) {
|
) {
|
||||||
const savedLR = mgr.getVisibleLogicalRange();
|
const savedLR = mgr.getVisibleLogicalRange();
|
||||||
|
const syncGen = ++indicatorReloadGenRef.current;
|
||||||
indicatorSyncInFlightRef.current = true;
|
indicatorSyncInFlightRef.current = true;
|
||||||
void mgr.refreshIndicators(paramsChangedConfigs).then(() => {
|
void mgr.refreshIndicators(paramsChangedConfigs).then(() => {
|
||||||
|
if (syncGen !== indicatorReloadGenRef.current) {
|
||||||
|
indicatorSyncInFlightRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
afterIndicatorPaneMutation(mgr);
|
afterIndicatorPaneMutation(mgr);
|
||||||
restoreLogicalRange(mgr, savedLR);
|
restoreLogicalRange(mgr, savedLR);
|
||||||
syncIndicatorTrackingRefs(indicators);
|
syncIndicatorTrackingRefs(indicators);
|
||||||
indicatorSyncInFlightRef.current = false;
|
indicatorSyncInFlightRef.current = false;
|
||||||
|
flushPendingIndicatorResync(mgr, syncGen);
|
||||||
});
|
});
|
||||||
} else if (isReorderOnly) {
|
} else if (isReorderOnly) {
|
||||||
reloadIndicatorsWithCover(mgr, indicators, () => {
|
reloadIndicatorsWithCover(mgr, indicators, () => {
|
||||||
@@ -1165,7 +1200,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, reloadIndicatorsWithCover, afterIndicatorPaneMutation, restoreLogicalRange, syncIndicatorTrackingRefs, repairMissingIndicators]);
|
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, reloadIndicatorsWithCover, afterIndicatorPaneMutation, restoreLogicalRange, syncIndicatorTrackingRefs, repairMissingIndicators, flushPendingIndicatorResync]);
|
||||||
|
|
||||||
// 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후)
|
// 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1325,6 +1360,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
onClose={() => setCtxMenu(null)}
|
onClose={() => setCtxMenu(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{chartMgr && (
|
||||||
|
<CandlePaneTimeAxisPortal
|
||||||
|
manager={chartMgr}
|
||||||
|
getContainer={() => containerRef.current}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{chartMgr && paneSeparatorOptions && (
|
{chartMgr && paneSeparatorOptions && (
|
||||||
<PaneSeparatorOverlay
|
<PaneSeparatorOverlay
|
||||||
manager={chartMgr}
|
manager={chartMgr}
|
||||||
@@ -1397,6 +1438,22 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CandlePaneTimeAxisPortal: React.FC<{
|
||||||
|
manager: ChartManager;
|
||||||
|
getContainer: () => HTMLElement | null;
|
||||||
|
}> = ({ manager, getContainer }) => {
|
||||||
|
const [el, setEl] = useState<HTMLElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const c = getContainer();
|
||||||
|
if (c) { setEl(c); return; }
|
||||||
|
const tid = setTimeout(() => setEl(getContainer()), 100);
|
||||||
|
return () => clearTimeout(tid);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
if (!el) return null;
|
||||||
|
return <CandlePaneTimeAxis manager={manager} containerEl={el} />;
|
||||||
|
};
|
||||||
|
|
||||||
const CandlePaneControlsPortal: React.FC<{
|
const CandlePaneControlsPortal: React.FC<{
|
||||||
manager: ChartManager;
|
manager: ChartManager;
|
||||||
getContainer: () => HTMLElement | null;
|
getContainer: () => HTMLElement | null;
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* 매매 시그널 알림 — 가로 스크롤 영역 + 우측 세로 레일(◀ ▶) 스크롤 버튼
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const SCROLL_STEP_RATIO = 0.85;
|
||||||
|
|
||||||
|
const IcChevronLeft = () => (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<polyline points="7 2 3 6 7 10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IcChevronRight = () => (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<polyline points="5 2 9 6 5 10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface TradeNotificationHScrollPaneProps {
|
||||||
|
className?: string;
|
||||||
|
/** 스크린리더용 영역 설명 */
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TradeNotificationHScrollPane: React.FC<TradeNotificationHScrollPaneProps> = ({
|
||||||
|
className = '',
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [canScroll, setCanScroll] = useState(false);
|
||||||
|
const [atStart, setAtStart] = useState(true);
|
||||||
|
const [atEnd, setAtEnd] = useState(true);
|
||||||
|
|
||||||
|
const syncScrollState = useCallback(() => {
|
||||||
|
const el = viewportRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const max = Math.max(0, el.scrollWidth - el.clientWidth);
|
||||||
|
const overflow = max > 2;
|
||||||
|
setCanScroll(overflow);
|
||||||
|
setAtStart(!overflow || el.scrollLeft <= 2);
|
||||||
|
setAtEnd(!overflow || el.scrollLeft >= max - 2);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = viewportRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
syncScrollState();
|
||||||
|
const ro = new ResizeObserver(() => syncScrollState());
|
||||||
|
ro.observe(el);
|
||||||
|
const mo = new MutationObserver(() => syncScrollState());
|
||||||
|
mo.observe(el, { childList: true, subtree: true });
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
mo.disconnect();
|
||||||
|
};
|
||||||
|
}, [syncScrollState, children]);
|
||||||
|
|
||||||
|
const scrollByStep = useCallback((dir: -1 | 1) => {
|
||||||
|
const el = viewportRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const step = Math.max(120, Math.round(el.clientWidth * SCROLL_STEP_RATIO));
|
||||||
|
el.scrollBy({ left: dir * step, behavior: 'smooth' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={['tnl-hscroll-pane', className].filter(Boolean).join(' ')}>
|
||||||
|
<div
|
||||||
|
ref={viewportRef}
|
||||||
|
className="tnl-hscroll-pane__viewport"
|
||||||
|
aria-label={label}
|
||||||
|
onScroll={syncScrollState}
|
||||||
|
>
|
||||||
|
<div className="tnl-hscroll-pane__content">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canScroll && (
|
||||||
|
<div className="tnl-hscroll-pane__rail" role="group" aria-label={`${label} 좌우 이동`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tnl-hscroll-pane__rail-btn"
|
||||||
|
disabled={atStart}
|
||||||
|
title="왼쪽으로"
|
||||||
|
aria-label="왼쪽으로 스크롤"
|
||||||
|
onClick={e => { e.stopPropagation(); scrollByStep(-1); }}
|
||||||
|
>
|
||||||
|
<IcChevronLeft />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tnl-hscroll-pane__rail-btn"
|
||||||
|
disabled={atEnd}
|
||||||
|
title="오른쪽으로"
|
||||||
|
aria-label="오른쪽으로 스크롤"
|
||||||
|
onClick={e => { e.stopPropagation(); scrollByStep(1); }}
|
||||||
|
>
|
||||||
|
<IcChevronRight />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TradeNotificationHScrollPane;
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 매매 시그널 알림 목록 행 — 좌: 요약 카드(고정) · 우: 캔들+지표 카드(가로 스크롤)
|
* 매매 시그널 알림 목록 행 — 좌: 요약 카드(고정) · 우: 캔들+지표 카드(가로 스크롤)
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { Theme } from '../../types';
|
import type { Theme } from '../../types';
|
||||||
|
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||||
|
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
|
||||||
|
import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot';
|
||||||
import type { StrategyDto } from '../../utils/backendApi';
|
import type { StrategyDto } from '../../utils/backendApi';
|
||||||
import { loadStrategy } from '../../utils/backendApi';
|
import { loadStrategies, loadStrategy } from '../../utils/backendApi';
|
||||||
import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext';
|
import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext';
|
||||||
import {
|
import {
|
||||||
buildSignalDetailRows,
|
buildSignalDetailRows,
|
||||||
@@ -20,6 +23,8 @@ import {
|
|||||||
import { formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry';
|
import { formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry';
|
||||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||||
import TradeSignalChartCard from './TradeSignalChartCard';
|
import TradeSignalChartCard from './TradeSignalChartCard';
|
||||||
|
import TradeNotificationSignalCard from './TradeNotificationSignalCard';
|
||||||
|
import TradeNotificationHScrollPane from './TradeNotificationHScrollPane';
|
||||||
|
|
||||||
const IcTrash = () => (
|
const IcTrash = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
@@ -38,6 +43,8 @@ interface Props {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
layoutMode?: TradeNotificationRowLayout;
|
layoutMode?: TradeNotificationRowLayout;
|
||||||
chartsEnabled?: boolean;
|
chartsEnabled?: boolean;
|
||||||
|
chartLiveReceiveHighlight?: boolean;
|
||||||
|
tickers?: Map<string, TickerData>;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onDelete: (e: React.MouseEvent) => void;
|
onDelete: (e: React.MouseEvent) => void;
|
||||||
onDetail: () => void;
|
onDetail: () => void;
|
||||||
@@ -52,6 +59,8 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
|||||||
isSelected,
|
isSelected,
|
||||||
layoutMode = 'list',
|
layoutMode = 'list',
|
||||||
chartsEnabled = true,
|
chartsEnabled = true,
|
||||||
|
chartLiveReceiveHighlight = true,
|
||||||
|
tickers,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDetail,
|
onDetail,
|
||||||
@@ -71,6 +80,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
|||||||
|
|
||||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||||
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
||||||
|
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||||
const strategyLabel = item.strategyName?.trim()
|
const strategyLabel = item.strategyName?.trim()
|
||||||
|| strategy?.name
|
|| strategy?.name
|
||||||
|| strategyRow?.value
|
|| strategyRow?.value
|
||||||
@@ -81,30 +91,36 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
|||||||
const [expandedChartKey, setExpandedChartKey] = useState<string | null>(null);
|
const [expandedChartKey, setExpandedChartKey] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isListLayout) return;
|
|
||||||
const el = rowRef.current;
|
const el = rowRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const io = new IntersectionObserver(
|
const io = new IntersectionObserver(
|
||||||
entries => {
|
entries => {
|
||||||
if (entries[0]?.isIntersecting) setInView(true);
|
setInView(entries[0]?.isIntersecting ?? false);
|
||||||
},
|
},
|
||||||
{ rootMargin: '120px 0px', threshold: 0.05 },
|
{ rootMargin: '120px 0px', threshold: 0.05 },
|
||||||
);
|
);
|
||||||
io.observe(el);
|
io.observe(el);
|
||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, [isListLayout]);
|
}, [item.id]);
|
||||||
|
|
||||||
|
const panelActive = isListLayout && inView;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isListLayout || !item.strategyId || !chartsEnabled) {
|
if (!panelActive || !isListLayout) {
|
||||||
setStrategy(undefined);
|
setStrategy(undefined);
|
||||||
|
setStrategies([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
void loadStrategies().then(list => {
|
||||||
|
if (!cancelled) setStrategies(list ?? []);
|
||||||
|
});
|
||||||
|
if (!item.strategyId) return () => { cancelled = true; };
|
||||||
void loadStrategy(item.strategyId).then(s => {
|
void loadStrategy(item.strategyId).then(s => {
|
||||||
if (!cancelled && s) setStrategy(s);
|
if (!cancelled && s) setStrategy(s);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [item.strategyId, chartsEnabled, isListLayout]);
|
}, [item.strategyId, panelActive, isListLayout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExpandedChartKey(null);
|
setExpandedChartKey(null);
|
||||||
@@ -121,10 +137,66 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
|||||||
}));
|
}));
|
||||||
}, [strategy, getParams, getVisualConfig, isListLayout]);
|
}, [strategy, getParams, getVisualConfig, isListLayout]);
|
||||||
|
|
||||||
const chartActive = isListLayout && chartsEnabled && inView;
|
|
||||||
const chartExpanded = isListLayout && expandedChartKey != null;
|
const chartExpanded = isListLayout && expandedChartKey != null;
|
||||||
|
const chartActive = isListLayout && chartsEnabled && panelActive;
|
||||||
|
const signalActive = isListLayout && panelActive && !chartExpanded;
|
||||||
|
const ticker = tickers?.get(item.market);
|
||||||
const expandedIndicator = indicatorCards.find(c => c.id === expandedChartKey);
|
const expandedIndicator = indicatorCards.find(c => c.id === expandedChartKey);
|
||||||
|
|
||||||
|
const signalSnapshotEnabled = isListLayout && panelActive && item.strategyId != null;
|
||||||
|
const { snapshot, loading: signalLoading } = useTradeNotificationSignalSnapshot(
|
||||||
|
item.market,
|
||||||
|
item.strategyId,
|
||||||
|
strategy,
|
||||||
|
signalSnapshotEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [chartActivitySeq, setChartActivitySeq] = useState(0);
|
||||||
|
const bumpChartActivity = useCallback(() => {
|
||||||
|
setChartActivitySeq(n => n + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const liveReceiveEnabled = panelActive && chartLiveReceiveHighlight;
|
||||||
|
const receiveSignal = useMemo(() => {
|
||||||
|
if (!liveReceiveEnabled) return null;
|
||||||
|
return [
|
||||||
|
ticker?.tradePrice ?? '',
|
||||||
|
ticker?.changeRate ?? '',
|
||||||
|
snapshot?.updatedAt ?? 0,
|
||||||
|
chartActivitySeq,
|
||||||
|
].join('|');
|
||||||
|
}, [
|
||||||
|
liveReceiveEnabled,
|
||||||
|
ticker?.tradePrice,
|
||||||
|
ticker?.changeRate,
|
||||||
|
snapshot?.updatedAt,
|
||||||
|
chartActivitySeq,
|
||||||
|
]);
|
||||||
|
const receiving = useLiveReceiveFlash(receiveSignal, liveReceiveEnabled);
|
||||||
|
|
||||||
|
/** 차트 영역 카드 수 — 알림상세(1) : 차트카드(N) 동일 비율 배분용 */
|
||||||
|
const chartCardCount = useMemo(() => {
|
||||||
|
if (!isListLayout) return 0;
|
||||||
|
if (chartExpanded) return 1;
|
||||||
|
let n = 1;
|
||||||
|
if (signalActive) n += 1;
|
||||||
|
n += indicatorCards.length;
|
||||||
|
if (item.strategyId != null && indicatorCards.length === 0 && chartActive) n += 1;
|
||||||
|
return n;
|
||||||
|
}, [
|
||||||
|
isListLayout,
|
||||||
|
chartExpanded,
|
||||||
|
signalActive,
|
||||||
|
indicatorCards.length,
|
||||||
|
item.strategyId,
|
||||||
|
chartActive,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rowGalleryStyle = useMemo(
|
||||||
|
() => ({ '--tnl-chart-card-count': String(chartCardCount) }) as React.CSSProperties,
|
||||||
|
[chartCardCount],
|
||||||
|
);
|
||||||
|
|
||||||
const summaryCard = (
|
const summaryCard = (
|
||||||
<article
|
<article
|
||||||
className={[
|
className={[
|
||||||
@@ -223,6 +295,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
|||||||
'tnl-row--grid',
|
'tnl-row--grid',
|
||||||
!item.isRead ? 'tnl-row--unread' : '',
|
!item.isRead ? 'tnl-row--unread' : '',
|
||||||
isSelected ? 'tnl-row--selected' : '',
|
isSelected ? 'tnl-row--selected' : '',
|
||||||
|
receiving ? 'tnl-row--receiving' : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
>
|
>
|
||||||
{summaryCard}
|
{summaryCard}
|
||||||
@@ -238,45 +311,77 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
|||||||
'tnl-row--gallery',
|
'tnl-row--gallery',
|
||||||
!item.isRead ? 'tnl-row--unread' : '',
|
!item.isRead ? 'tnl-row--unread' : '',
|
||||||
isSelected ? 'tnl-row--selected' : '',
|
isSelected ? 'tnl-row--selected' : '',
|
||||||
|
receiving ? 'tnl-row--receiving' : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
>
|
>
|
||||||
<div className={['tnl-row-gallery', chartExpanded ? 'tnl-row-gallery--chart-expanded' : ''].filter(Boolean).join(' ')}>
|
<div
|
||||||
{summaryCard}
|
className={['tnl-row-gallery', chartExpanded ? 'tnl-row-gallery--chart-expanded' : ''].filter(Boolean).join(' ')}
|
||||||
|
style={rowGalleryStyle}
|
||||||
|
>
|
||||||
|
<TradeNotificationHScrollPane
|
||||||
|
className="tnl-row-gallery-detail"
|
||||||
|
label="알림 상세"
|
||||||
|
>
|
||||||
|
{summaryCard}
|
||||||
|
</TradeNotificationHScrollPane>
|
||||||
|
|
||||||
{chartExpanded ? (
|
<TradeNotificationHScrollPane
|
||||||
<div className="tnl-charts-expanded" aria-label="차트 전체보기">
|
className={['tnl-row-gallery-charts', chartExpanded ? 'tnl-row-gallery-charts--expanded' : ''].filter(Boolean).join(' ')}
|
||||||
{expandedChartKey === 'candle' && (
|
label={chartExpanded ? '차트 전체보기' : '신호·캔들·지표'}
|
||||||
<TradeSignalChartCard
|
>
|
||||||
label="캔들"
|
{chartExpanded ? (
|
||||||
meta={candleKo}
|
<div className="tnl-charts-expanded-inner">
|
||||||
market={item.market}
|
{expandedChartKey === 'candle' && (
|
||||||
timeframe={chartTf}
|
<TradeSignalChartCard
|
||||||
theme={theme}
|
label="캔들"
|
||||||
indicators={[]}
|
meta={candleKo}
|
||||||
enabled={chartActive}
|
market={item.market}
|
||||||
expanded
|
timeframe={chartTf}
|
||||||
onExpand={() => setExpandedChartKey('candle')}
|
theme={theme}
|
||||||
onRestore={() => setExpandedChartKey(null)}
|
indicators={[]}
|
||||||
/>
|
enabled={chartActive}
|
||||||
)}
|
expanded
|
||||||
{expandedIndicator && (
|
onExpand={() => setExpandedChartKey('candle')}
|
||||||
<TradeSignalChartCard
|
onRestore={() => setExpandedChartKey(null)}
|
||||||
label={expandedIndicator.label}
|
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
|
||||||
meta={candleKo}
|
/>
|
||||||
market={item.market}
|
)}
|
||||||
timeframe={chartTf}
|
{expandedIndicator && (
|
||||||
theme={theme}
|
<TradeSignalChartCard
|
||||||
indicators={expandedIndicator.config}
|
label={expandedIndicator.label}
|
||||||
enabled={chartActive}
|
meta={candleKo}
|
||||||
expanded
|
market={item.market}
|
||||||
onExpand={() => setExpandedChartKey(expandedIndicator.id)}
|
timeframe={chartTf}
|
||||||
onRestore={() => setExpandedChartKey(null)}
|
theme={theme}
|
||||||
/>
|
indicators={expandedIndicator.config}
|
||||||
)}
|
enabled={chartActive}
|
||||||
</div>
|
expanded
|
||||||
) : (
|
onExpand={() => setExpandedChartKey(expandedIndicator.id)}
|
||||||
<div className="tnl-charts-scroll" aria-label="캔들·지표 차트">
|
onRestore={() => setExpandedChartKey(null)}
|
||||||
|
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="tnl-charts-track">
|
<div className="tnl-charts-track">
|
||||||
|
{signalActive && (
|
||||||
|
<TradeNotificationSignalCard
|
||||||
|
market={item.market}
|
||||||
|
strategyId={item.strategyId}
|
||||||
|
strategy={strategy}
|
||||||
|
strategies={strategies}
|
||||||
|
strategyLabel={strategyLabel}
|
||||||
|
meta={strategyLabel}
|
||||||
|
candleType={item.candleType}
|
||||||
|
theme={theme}
|
||||||
|
ticker={ticker}
|
||||||
|
enabled={signalActive}
|
||||||
|
snapshot={snapshot}
|
||||||
|
signalLoading={signalLoading}
|
||||||
|
onExpandCharts={() => setExpandedChartKey('candle')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<TradeSignalChartCard
|
<TradeSignalChartCard
|
||||||
label="캔들"
|
label="캔들"
|
||||||
meta={candleKo}
|
meta={candleKo}
|
||||||
@@ -288,6 +393,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
onExpand={() => setExpandedChartKey('candle')}
|
onExpand={() => setExpandedChartKey('candle')}
|
||||||
onRestore={() => setExpandedChartKey(null)}
|
onRestore={() => setExpandedChartKey(null)}
|
||||||
|
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{indicatorCards.map(card => (
|
{indicatorCards.map(card => (
|
||||||
@@ -303,6 +409,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
onExpand={() => setExpandedChartKey(card.id)}
|
onExpand={() => setExpandedChartKey(card.id)}
|
||||||
onRestore={() => setExpandedChartKey(null)}
|
onRestore={() => setExpandedChartKey(null)}
|
||||||
|
onRealtimeActivity={chartActive ? bumpChartActivity : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -312,8 +419,8 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</TradeNotificationHScrollPane>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 매매 시그널 알림 — 가상매매 VirtualTargetCard(신호 패널) 슬롯
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||||
|
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||||
|
import type { StrategyDto } from '../../utils/backendApi';
|
||||||
|
import type { Theme } from '../../types';
|
||||||
|
import TradeNotificationSignalSummary from './TradeNotificationSignalSummary';
|
||||||
|
|
||||||
|
export interface TradeNotificationSignalCardProps {
|
||||||
|
market: string;
|
||||||
|
strategyId: number | null | undefined;
|
||||||
|
strategy?: StrategyDto;
|
||||||
|
strategies?: StrategyDto[];
|
||||||
|
strategyLabel: string;
|
||||||
|
meta?: string;
|
||||||
|
candleType?: string;
|
||||||
|
theme?: Theme;
|
||||||
|
ticker?: TickerData;
|
||||||
|
enabled?: boolean;
|
||||||
|
snapshot?: VirtualIndicatorSnapshot;
|
||||||
|
signalLoading?: boolean;
|
||||||
|
onExpandCharts?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TradeNotificationSignalCard: React.FC<TradeNotificationSignalCardProps> = props => (
|
||||||
|
<div
|
||||||
|
className="tnl-signal-slot"
|
||||||
|
aria-label="실시간 신호 현황"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<TradeNotificationSignalSummary embedded {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default TradeNotificationSignalCard;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 매매 시그널 알림 목록 — 가상매매 종목 카드(요약·신호)와 동일 UI · 실시간 신호 갱신
|
||||||
|
*/
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||||
|
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||||
|
import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot';
|
||||||
|
import type { StrategyDto } from '../../utils/backendApi';
|
||||||
|
import type { Theme } from '../../types';
|
||||||
|
import VirtualTargetCard, { type VirtualCardDisplayMode } from '../virtual/VirtualTargetCard';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
market: string;
|
||||||
|
strategyId: number | null | undefined;
|
||||||
|
strategy?: StrategyDto;
|
||||||
|
strategies?: StrategyDto[];
|
||||||
|
globalStrategyId?: number | null;
|
||||||
|
candleType?: string;
|
||||||
|
theme?: Theme;
|
||||||
|
ticker?: TickerData;
|
||||||
|
enabled?: boolean;
|
||||||
|
/** tnl-chart-card 본문에 삽입 */
|
||||||
|
embedded?: boolean;
|
||||||
|
/** 부모에서 스냅샷 공유 시(행 테두리 플래시와 중복 폴링 방지) */
|
||||||
|
snapshot?: VirtualIndicatorSnapshot;
|
||||||
|
signalLoading?: boolean;
|
||||||
|
/** 차트 영역 펼쳐보기 (헤더 확대 버튼) */
|
||||||
|
onExpandCharts?: () => void;
|
||||||
|
/** 푸터 전략명 (읽기 전용) */
|
||||||
|
strategyLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TradeNotificationSignalSummary: React.FC<Props> = ({
|
||||||
|
market,
|
||||||
|
strategyId,
|
||||||
|
strategy,
|
||||||
|
strategies = [],
|
||||||
|
globalStrategyId = null,
|
||||||
|
theme = 'dark',
|
||||||
|
ticker,
|
||||||
|
enabled = true,
|
||||||
|
embedded = false,
|
||||||
|
snapshot: snapshotProp,
|
||||||
|
signalLoading: signalLoadingProp,
|
||||||
|
onExpandCharts,
|
||||||
|
strategyLabel,
|
||||||
|
}) => {
|
||||||
|
const active = enabled && strategyId != null;
|
||||||
|
const [displayMode, setDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
||||||
|
|
||||||
|
const internal = useTradeNotificationSignalSnapshot(
|
||||||
|
market,
|
||||||
|
strategyId,
|
||||||
|
strategy,
|
||||||
|
active && snapshotProp === undefined,
|
||||||
|
);
|
||||||
|
const snapshot = snapshotProp ?? internal.snapshot;
|
||||||
|
const loading = signalLoadingProp ?? internal.loading;
|
||||||
|
|
||||||
|
const strategiesList = useMemo(() => {
|
||||||
|
if (strategies.length > 0) return strategies;
|
||||||
|
return strategy ? [strategy] : [];
|
||||||
|
}, [strategies, strategy]);
|
||||||
|
|
||||||
|
const card = (
|
||||||
|
<VirtualTargetCard
|
||||||
|
market={market}
|
||||||
|
strategy={strategy}
|
||||||
|
strategies={strategiesList}
|
||||||
|
strategyId={strategyId ?? null}
|
||||||
|
globalStrategyId={globalStrategyId}
|
||||||
|
snapshot={snapshot}
|
||||||
|
signalLoading={loading}
|
||||||
|
running={active}
|
||||||
|
liveStatus="live"
|
||||||
|
viewMode="summary"
|
||||||
|
displayMode={displayMode}
|
||||||
|
onDisplayModeChange={embedded ? undefined : setDisplayMode}
|
||||||
|
onEnterFocus={embedded ? undefined : onExpandCharts}
|
||||||
|
headVariant={embedded ? 'inline-quote' : 'default'}
|
||||||
|
theme={theme}
|
||||||
|
ticker={ticker}
|
||||||
|
chartLiveReceiveHighlight
|
||||||
|
readOnlyStrategyLabel={strategyLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`tnl-signal-summary--embedded-wrap se-page se-page--${theme}`}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{card}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TradeNotificationSignalSummary;
|
||||||
@@ -35,6 +35,7 @@ export interface TradeSignalChartCardProps {
|
|||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onExpand: () => void;
|
onExpand: () => void;
|
||||||
onRestore: () => void;
|
onRestore: () => void;
|
||||||
|
onRealtimeActivity?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TradeSignalChartCard: React.FC<TradeSignalChartCardProps> = ({
|
const TradeSignalChartCard: React.FC<TradeSignalChartCardProps> = ({
|
||||||
@@ -48,6 +49,7 @@ const TradeSignalChartCard: React.FC<TradeSignalChartCardProps> = ({
|
|||||||
expanded,
|
expanded,
|
||||||
onExpand,
|
onExpand,
|
||||||
onRestore,
|
onRestore,
|
||||||
|
onRealtimeActivity,
|
||||||
}) => (
|
}) => (
|
||||||
<section
|
<section
|
||||||
className={[
|
className={[
|
||||||
@@ -67,6 +69,7 @@ const TradeSignalChartCard: React.FC<TradeSignalChartCardProps> = ({
|
|||||||
indicators={indicators}
|
indicators={indicators}
|
||||||
enabled={enabled}
|
enabled={enabled}
|
||||||
fillHeight={expanded}
|
fillHeight={expanded}
|
||||||
|
onRealtimeActivity={onRealtimeActivity}
|
||||||
/>
|
/>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface Props {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** 전체보기 모드 — 부모 높이에 맞춤 */
|
/** 전체보기 모드 — 부모 높이에 맞춤 */
|
||||||
fillHeight?: boolean;
|
fillHeight?: boolean;
|
||||||
|
/** STOMP/WS 실시간 봉 수신 시 (목록 행 형광 플래시 등) */
|
||||||
|
onRealtimeActivity?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
@@ -36,6 +38,7 @@ const TradeSignalMiniChart: React.FC<Props> = ({
|
|||||||
indicators = [],
|
indicators = [],
|
||||||
enabled = true,
|
enabled = true,
|
||||||
fillHeight = false,
|
fillHeight = false,
|
||||||
|
onRealtimeActivity,
|
||||||
}) => {
|
}) => {
|
||||||
const managerRef = useRef<ChartManager | null>(null);
|
const managerRef = useRef<ChartManager | null>(null);
|
||||||
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
|
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -70,11 +73,13 @@ const TradeSignalMiniChart: React.FC<Props> = ({
|
|||||||
|
|
||||||
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
||||||
applyRealtimeBar(bar, false);
|
applyRealtimeBar(bar, false);
|
||||||
}, [applyRealtimeBar]);
|
onRealtimeActivity?.();
|
||||||
|
}, [applyRealtimeBar, onRealtimeActivity]);
|
||||||
|
|
||||||
const handleNewCandle = useCallback((bar: OHLCVBar) => {
|
const handleNewCandle = useCallback((bar: OHLCVBar) => {
|
||||||
applyRealtimeBar(bar, true);
|
applyRealtimeBar(bar, true);
|
||||||
}, [applyRealtimeBar]);
|
onRealtimeActivity?.();
|
||||||
|
}, [applyRealtimeBar, onRealtimeActivity]);
|
||||||
|
|
||||||
const useUpbit = isUpbitMarket(market);
|
const useUpbit = isUpbitMarket(market);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import { useSignalVisualRailHeight } from '../../hooks/useSignalVisualRailHeight';
|
||||||
import type { TrendSearchConditionDto } from '../../utils/trendSearchApi';
|
import type { TrendSearchConditionDto } from '../../utils/trendSearchApi';
|
||||||
import { computeMatchRate, getTrafficLightState } from '../../utils/virtualSignalMetrics';
|
import { computeMatchRate, getTrafficLightState } from '../../utils/virtualSignalMetrics';
|
||||||
import { buildTrendSearchMetrics } from '../../utils/trendSearchMetrics';
|
import { buildTrendSearchMetrics } from '../../utils/trendSearchMetrics';
|
||||||
@@ -31,6 +32,11 @@ const TrendSearchCardSignalPanel: React.FC<Props> = ({
|
|||||||
() => getTrafficLightState(resolvedMatch, metrics),
|
() => getTrafficLightState(resolvedMatch, metrics),
|
||||||
[resolvedMatch, metrics],
|
[resolvedMatch, metrics],
|
||||||
);
|
);
|
||||||
|
const { visualRef, lightRef } = useSignalVisualRailHeight([
|
||||||
|
conditions.length,
|
||||||
|
resolvedMatch,
|
||||||
|
trafficState,
|
||||||
|
]);
|
||||||
|
|
||||||
if (conditions.length === 0) {
|
if (conditions.length === 0) {
|
||||||
return <p className="vtd-muted vtd-card-empty">조건 데이터 없음</p>;
|
return <p className="vtd-muted vtd-card-empty">조건 데이터 없음</p>;
|
||||||
@@ -46,12 +52,18 @@ const TrendSearchCardSignalPanel: React.FC<Props> = ({
|
|||||||
<div className="vtd-sig-panel-wrap vtd-sig-panel-wrap--detail">
|
<div className="vtd-sig-panel-wrap vtd-sig-panel-wrap--detail">
|
||||||
<div className="vtd-sig-panel vtd-sig-panel--detail-top">
|
<div className="vtd-sig-panel vtd-sig-panel--detail-top">
|
||||||
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
||||||
<div className="vtd-sig-visual">
|
<div className="vtd-sig-visual" ref={visualRef}>
|
||||||
<div className="vtd-sig-visual-eq">
|
<div className="vtd-sig-visual-pane vtd-sig-visual-eq">
|
||||||
<VirtualSignalEqualizer matchRate={resolvedMatch} />
|
<VirtualSignalEqualizer matchRate={resolvedMatch} />
|
||||||
</div>
|
</div>
|
||||||
<VirtualConditionList metrics={metrics} />
|
<div className="vtd-sig-visual-pane vtd-sig-visual-heat">
|
||||||
<VirtualSignalTrafficLight state={trafficState} matchRate={resolvedMatch} />
|
<VirtualConditionList metrics={metrics} />
|
||||||
|
</div>
|
||||||
|
<div className="vtd-sig-visual-pane vtd-sig-visual-light">
|
||||||
|
<div ref={lightRef} className="vtd-sig-light-rail">
|
||||||
|
<VirtualSignalTrafficLight state={trafficState} matchRate={resolvedMatch} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
|
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
|
||||||
|
|||||||
@@ -38,29 +38,28 @@ const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
|
|||||||
return items;
|
return items;
|
||||||
}, [litCount, rate]);
|
}, [litCount, rate]);
|
||||||
|
|
||||||
const ticks = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0];
|
/** 신호등 열 높이에 맞춘 20% 눈금 (가상매매·알림 공통) */
|
||||||
|
const ticks = [100, 80, 60, 40, 20, 0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vtd-sig-eq">
|
<div className="vtd-sig-eq">
|
||||||
<div className="vtd-sig-eq-scale">
|
<div className="vtd-sig-eq-scale" aria-hidden>
|
||||||
{ticks.map(t => (
|
{ticks.map(t => (
|
||||||
<span key={t} className="vtd-sig-eq-tick">{t}%</span>
|
<span key={t} className="vtd-sig-eq-tick">{t}%</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="vtd-sig-eq-column">
|
<div className="vtd-sig-eq-bar-wrap">
|
||||||
<div className="vtd-sig-eq-bar-wrap">
|
<div className="vtd-sig-eq-bar">
|
||||||
<div className="vtd-sig-eq-bar">
|
{segments.map((seg, i) => (
|
||||||
{segments.map((seg, i) => (
|
<div
|
||||||
<div
|
key={i}
|
||||||
key={i}
|
className={[
|
||||||
className={[
|
'vtd-sig-eq-seg',
|
||||||
'vtd-sig-eq-seg',
|
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
|
||||||
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
|
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
|
||||||
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
|
].filter(Boolean).join(' ')}
|
||||||
].filter(Boolean).join(' ')}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{rate >= 100 && (
|
{rate >= 100 && (
|
||||||
<div className="vtd-sig-eq-badge">100% MATCH</div>
|
<div className="vtd-sig-eq-badge">100% MATCH</div>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import VirtualTargetCardFoot from './VirtualTargetCardFoot';
|
|||||||
|
|
||||||
export type VirtualCardDisplayMode = 'signal' | 'chart';
|
export type VirtualCardDisplayMode = 'signal' | 'chart';
|
||||||
|
|
||||||
|
/** default: 우측 액션 버튼 · inline-quote: 우측 현재가·등락·실시간 배지 */
|
||||||
|
export type VirtualCardHeadVariant = 'default' | 'inline-quote';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
market: string;
|
market: string;
|
||||||
strategy: StrategyDto | undefined;
|
strategy: StrategyDto | undefined;
|
||||||
@@ -43,6 +46,10 @@ interface Props {
|
|||||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||||
chartLiveReceiveHighlight?: boolean;
|
chartLiveReceiveHighlight?: boolean;
|
||||||
ticker?: TickerData;
|
ticker?: TickerData;
|
||||||
|
/** 알림 목록 등 — 푸터 전략 셀렉트 읽기 전용 라벨 */
|
||||||
|
readOnlyStrategyLabel?: string;
|
||||||
|
/** 헤더 우측 레이아웃 (알림 목록 신호 슬롯: inline-quote) */
|
||||||
|
headVariant?: VirtualCardHeadVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VirtualTargetCard: React.FC<Props> = ({
|
const VirtualTargetCard: React.FC<Props> = ({
|
||||||
@@ -69,7 +76,10 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
chartPaneSeparator,
|
chartPaneSeparator,
|
||||||
chartLiveReceiveHighlight = true,
|
chartLiveReceiveHighlight = true,
|
||||||
ticker,
|
ticker,
|
||||||
|
readOnlyStrategyLabel,
|
||||||
|
headVariant = 'default',
|
||||||
}) => {
|
}) => {
|
||||||
|
const inlineHeadQuote = headVariant === 'inline-quote';
|
||||||
const ko = getKoreanName(market);
|
const ko = getKoreanName(market);
|
||||||
const sym = market.replace(/^KRW-/, '');
|
const sym = market.replace(/^KRW-/, '');
|
||||||
const rows = snapshot?.rows ?? [];
|
const rows = snapshot?.rows ?? [];
|
||||||
@@ -101,6 +111,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
highlightReceiving ? 'vtd-card--receiving' : '',
|
highlightReceiving ? 'vtd-card--receiving' : '',
|
||||||
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
|
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
|
||||||
isChart ? 'vtd-card--chart-mode' : '',
|
isChart ? 'vtd-card--chart-mode' : '',
|
||||||
|
inlineHeadQuote ? 'vtd-card--head-inline-quote' : '',
|
||||||
selected ? 'vtd-card--selected' : '',
|
selected ? 'vtd-card--selected' : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
@@ -121,51 +132,60 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
<span className="vtd-card-sym">{sym}</span>
|
<span className="vtd-card-sym">{sym}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="vtd-card-head-actions">
|
{inlineHeadQuote ? (
|
||||||
{onEnterFocus && (
|
<div className="vtd-card-head-quote" onClick={e => e.stopPropagation()}>
|
||||||
<button
|
<VirtualTargetQuote market={market} ticker={ticker} compact showPriceLabel={false} />
|
||||||
type="button"
|
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
|
||||||
className="vtd-card-focus-btn"
|
|
||||||
onClick={e => { e.stopPropagation(); onEnterFocus(); }}
|
|
||||||
title="전체화면 보기 (차트 + 신호)"
|
|
||||||
aria-label="전체화면 보기"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
||||||
<polyline points="5,1 1,1 1,5" />
|
|
||||||
<polyline points="9,13 13,13 13,9" />
|
|
||||||
<line x1="1" y1="1" x2="6" y2="6" />
|
|
||||||
<line x1="13" y1="13" x2="8" y2="8" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="vtd-card-mode-toggle" role="group" aria-label="이 종목 표시" onClick={e => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`vtd-card-mode-btn${displayMode === 'signal' ? ' vtd-card-mode-btn--on' : ''}`}
|
|
||||||
onClick={() => onDisplayModeChange?.('signal')}
|
|
||||||
title="이 종목만 신호 보기"
|
|
||||||
>
|
|
||||||
신호
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`vtd-card-mode-btn${displayMode === 'chart' ? ' vtd-card-mode-btn--on' : ''}`}
|
|
||||||
onClick={() => onDisplayModeChange?.('chart')}
|
|
||||||
title="이 종목만 차트 보기"
|
|
||||||
>
|
|
||||||
차트
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="vtd-card-head-actions">
|
||||||
|
{onEnterFocus && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vtd-card-focus-btn"
|
||||||
|
onClick={e => { e.stopPropagation(); onEnterFocus(); }}
|
||||||
|
title="전체화면 보기 (차트 + 신호)"
|
||||||
|
aria-label="전체화면 보기"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<polyline points="5,1 1,1 1,5" />
|
||||||
|
<polyline points="9,13 13,13 13,9" />
|
||||||
|
<line x1="1" y1="1" x2="6" y2="6" />
|
||||||
|
<line x1="13" y1="13" x2="8" y2="8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="vtd-card-mode-toggle" role="group" aria-label="이 종목 표시" onClick={e => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`vtd-card-mode-btn${displayMode === 'signal' ? ' vtd-card-mode-btn--on' : ''}`}
|
||||||
|
onClick={() => onDisplayModeChange?.('signal')}
|
||||||
|
title="이 종목만 신호 보기"
|
||||||
|
>
|
||||||
|
신호
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`vtd-card-mode-btn${displayMode === 'chart' ? ' vtd-card-mode-btn--on' : ''}`}
|
||||||
|
onClick={() => onDisplayModeChange?.('chart')}
|
||||||
|
title="이 종목만 차트 보기"
|
||||||
|
>
|
||||||
|
차트
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="vtd-card-meta">
|
{!inlineHeadQuote && (
|
||||||
<VirtualTargetQuote market={market} ticker={ticker} compact />
|
<div className="vtd-card-meta">
|
||||||
{!isChart && isDetail && evalCount > 0 && (
|
<VirtualTargetQuote market={market} ticker={ticker} compact />
|
||||||
<span className="vtd-card-met-count">{metCount}/{evalCount} 조건 충족</span>
|
{!isChart && isDetail && evalCount > 0 && (
|
||||||
)}
|
<span className="vtd-card-met-count">{metCount}/{evalCount} 조건 충족</span>
|
||||||
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
|
)}
|
||||||
</div>
|
{running && <VirtualLiveBadge status={liveStatus} receiving={receiving} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isChart ? (
|
{isChart ? (
|
||||||
<VirtualTargetCardChart
|
<VirtualTargetCardChart
|
||||||
@@ -193,6 +213,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
strategyId={strategyId}
|
strategyId={strategyId}
|
||||||
globalStrategyId={globalStrategyId}
|
globalStrategyId={globalStrategyId}
|
||||||
onStrategyChange={onStrategyChange}
|
onStrategyChange={onStrategyChange}
|
||||||
|
readOnlyStrategyLabel={readOnlyStrategyLabel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface Props {
|
|||||||
strategyId: number | null;
|
strategyId: number | null;
|
||||||
globalStrategyId: number | null;
|
globalStrategyId: number | null;
|
||||||
onStrategyChange?: (strategyId: number | null) => void;
|
onStrategyChange?: (strategyId: number | null) => void;
|
||||||
|
/** 변경 불가 시 셀렉트 대신 표시할 전략명 (알림 목록 등) */
|
||||||
|
readOnlyStrategyLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 카드 하단 — 갱신 시각(좌) · 전략 드롭다운(우) */
|
/** 카드 하단 — 갱신 시각(좌) · 전략 드롭다운(우) */
|
||||||
@@ -23,29 +25,48 @@ const VirtualTargetCardFoot: React.FC<Props> = ({
|
|||||||
strategyId,
|
strategyId,
|
||||||
globalStrategyId,
|
globalStrategyId,
|
||||||
onStrategyChange,
|
onStrategyChange,
|
||||||
}) => (
|
readOnlyStrategyLabel,
|
||||||
<div className="vtd-card-foot">
|
}) => {
|
||||||
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
const resolvedName =
|
||||||
<div className="vtd-card-foot-controls" onClick={e => e.stopPropagation()}>
|
readOnlyStrategyLabel?.trim()
|
||||||
<label className="vtd-card-foot-field">
|
|| strategies.find(s => s.id === strategyId)?.name
|
||||||
<select
|
|| '';
|
||||||
className="vtd-card-foot-select"
|
|
||||||
aria-label="투자전략"
|
return (
|
||||||
value={targetStrategySelectValue({ strategyId })}
|
<div className="vtd-card-foot">
|
||||||
onChange={e => {
|
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
||||||
onStrategyChange?.(parseTargetStrategySelectValue(e.target.value));
|
<div className="vtd-card-foot-controls" onClick={e => e.stopPropagation()}>
|
||||||
}}
|
<label className="vtd-card-foot-field">
|
||||||
>
|
{readOnlyStrategyLabel != null && !onStrategyChange ? (
|
||||||
<option value={targetStrategySelectValue({ strategyId: null })}>
|
<span
|
||||||
{defaultStrategyOptionLabel(globalStrategyId, strategies)}
|
className="vtd-card-foot-select vtd-card-foot-select--readonly"
|
||||||
</option>
|
title={resolvedName}
|
||||||
{strategies.map(s => (
|
>
|
||||||
<option key={s.id} value={String(s.id)}>{s.name}</option>
|
{resolvedName || '전략'}
|
||||||
))}
|
</span>
|
||||||
</select>
|
) : (
|
||||||
</label>
|
<select
|
||||||
|
className="vtd-card-foot-select"
|
||||||
|
aria-label="투자전략"
|
||||||
|
value={targetStrategySelectValue({ strategyId })}
|
||||||
|
disabled={!onStrategyChange}
|
||||||
|
title={!onStrategyChange ? resolvedName : undefined}
|
||||||
|
onChange={e => {
|
||||||
|
onStrategyChange?.(parseTargetStrategySelectValue(e.target.value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={targetStrategySelectValue({ strategyId: null })}>
|
||||||
|
{defaultStrategyOptionLabel(globalStrategyId, strategies)}
|
||||||
|
</option>
|
||||||
|
{strategies.map(s => (
|
||||||
|
<option key={s.id} value={String(s.id)}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export default VirtualTargetCardFoot;
|
export default VirtualTargetCardFoot;
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ interface Props {
|
|||||||
ticker?: TickerData;
|
ticker?: TickerData;
|
||||||
/** 카드 메타 행 — 현재가만 간략 표시 */
|
/** 카드 메타 행 — 현재가만 간략 표시 */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** compact 시 "현재가" 라벨 표시 (기본 true) */
|
||||||
|
showPriceLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VirtualTargetQuote: React.FC<Props> = ({ market, ticker, compact = false }) => {
|
const VirtualTargetQuote: React.FC<Props> = ({ market, ticker, compact = false, showPriceLabel = true }) => {
|
||||||
const code = market.replace(/^KRW-/, '');
|
const code = market.replace(/^KRW-/, '');
|
||||||
const change: ChangeDir = ticker?.change ?? 'EVEN';
|
const change: ChangeDir = ticker?.change ?? 'EVEN';
|
||||||
const colorCls = change === 'RISE' ? 'vtd-target-up' : change === 'FALL' ? 'vtd-target-dn' : 'vtd-target-even';
|
const colorCls = change === 'RISE' ? 'vtd-target-up' : change === 'FALL' ? 'vtd-target-dn' : 'vtd-target-even';
|
||||||
@@ -54,7 +56,7 @@ const VirtualTargetQuote: React.FC<Props> = ({ market, ticker, compact = false }
|
|||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<div className={`vtd-target-quote vtd-target-quote--compact ${flashClass}`.trim()} aria-label="현재가">
|
<div className={`vtd-target-quote vtd-target-quote--compact ${flashClass}`.trim()} aria-label="현재가">
|
||||||
<span className="vtd-card-price-label">현재가</span>
|
{showPriceLabel && <span className="vtd-card-price-label">현재가</span>}
|
||||||
<span className={`vtd-target-price ${colorCls}`}>
|
<span className={`vtd-target-price ${colorCls}`}>
|
||||||
{ticker ? fmtKrw(ticker.tradePrice) : '-'}
|
{ticker ? fmtKrw(ticker.tradePrice) : '-'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import { useSignalVisualRailHeight } from '../../hooks/useSignalVisualRailHeight';
|
||||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||||
import {
|
import {
|
||||||
buildConditionMetrics,
|
buildConditionMetrics,
|
||||||
@@ -36,6 +37,12 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
|||||||
[metrics, snapshot?.matchRate],
|
[metrics, snapshot?.matchRate],
|
||||||
);
|
);
|
||||||
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
|
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
|
||||||
|
const { visualRef, lightRef } = useSignalVisualRailHeight([
|
||||||
|
rows.length,
|
||||||
|
matchRate,
|
||||||
|
trafficState,
|
||||||
|
viewMode,
|
||||||
|
]);
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -58,12 +65,18 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<div className={`vtd-sig-panel${isDetail ? ' vtd-sig-panel--detail-top' : ' vtd-sig-panel--summary'}`}>
|
<div className={`vtd-sig-panel${isDetail ? ' vtd-sig-panel--detail-top' : ' vtd-sig-panel--summary'}`}>
|
||||||
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
||||||
<div className="vtd-sig-visual">
|
<div className="vtd-sig-visual" ref={visualRef}>
|
||||||
<div className="vtd-sig-visual-eq">
|
<div className="vtd-sig-visual-pane vtd-sig-visual-eq">
|
||||||
<VirtualSignalEqualizer matchRate={matchRate} />
|
<VirtualSignalEqualizer matchRate={matchRate} />
|
||||||
</div>
|
</div>
|
||||||
<VirtualConditionList metrics={metrics} />
|
<div className="vtd-sig-visual-pane vtd-sig-visual-heat">
|
||||||
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
|
<VirtualConditionList metrics={metrics} />
|
||||||
|
</div>
|
||||||
|
<div className="vtd-sig-visual-pane vtd-sig-visual-light">
|
||||||
|
<div ref={lightRef} className="vtd-sig-light-rail">
|
||||||
|
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isDetail && (
|
{isDetail && (
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ import { resolveChartLegendOptions } from '../types/chartLegend';
|
|||||||
import type { ChartLegendVisibility } from '../types/chartLegend';
|
import type { ChartLegendVisibility } from '../types/chartLegend';
|
||||||
import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
|
import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
|
||||||
import { DEFAULT_SYNC, type SyncOptions } from '../utils/layoutTypes';
|
import { DEFAULT_SYNC, type SyncOptions } from '../utils/layoutTypes';
|
||||||
|
import {
|
||||||
|
DEFAULT_CHART_TIME_FORMAT,
|
||||||
|
normalizeChartTimeFormat,
|
||||||
|
setChartTimeFormat,
|
||||||
|
} from '../utils/chartTimeFormat';
|
||||||
import { DEFAULT_DISPLAY_TIMEZONE, normalizeTimezone, setDisplayTimezone } from '../utils/timezone';
|
import { DEFAULT_DISPLAY_TIMEZONE, normalizeTimezone, setDisplayTimezone } from '../utils/timezone';
|
||||||
import {
|
import {
|
||||||
clampVirtualTargetMax,
|
clampVirtualTargetMax,
|
||||||
@@ -170,6 +175,7 @@ export function resolveAppDefaults(s: AppSettingsDto) {
|
|||||||
liveAutoTradeBudgetPct: s.liveAutoTradeBudgetPct ?? 95,
|
liveAutoTradeBudgetPct: s.liveAutoTradeBudgetPct ?? 95,
|
||||||
fcmPushEnabled: s.fcmPushEnabled ?? false,
|
fcmPushEnabled: s.fcmPushEnabled ?? false,
|
||||||
displayTimezone: normalizeTimezone(s.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE),
|
displayTimezone: normalizeTimezone(s.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE),
|
||||||
|
chartTimeFormat: normalizeChartTimeFormat(s.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT),
|
||||||
trendSearchSettings: resolveTrendSearchAppSettings(
|
trendSearchSettings: resolveTrendSearchAppSettings(
|
||||||
s.trendSearchSettings as Partial<TrendSearchAppSettings> | null | undefined,
|
s.trendSearchSettings as Partial<TrendSearchAppSettings> | null | undefined,
|
||||||
),
|
),
|
||||||
@@ -194,6 +200,7 @@ export function useAppSettings(sessionKey = 0) {
|
|||||||
if (_cache !== null) {
|
if (_cache !== null) {
|
||||||
setSettings(_cache);
|
setSettings(_cache);
|
||||||
setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
||||||
|
setChartTimeFormat(normalizeChartTimeFormat(_cache.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT));
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
} else {
|
} else {
|
||||||
setIsLoaded(false);
|
setIsLoaded(false);
|
||||||
@@ -202,6 +209,7 @@ export function useAppSettings(sessionKey = 0) {
|
|||||||
} else if (_cache !== null) {
|
} else if (_cache !== null) {
|
||||||
setSettings(_cache);
|
setSettings(_cache);
|
||||||
setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
||||||
|
setChartTimeFormat(normalizeChartTimeFormat(_cache.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT));
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
} else {
|
} else {
|
||||||
setIsLoaded(false);
|
setIsLoaded(false);
|
||||||
@@ -211,6 +219,7 @@ export function useAppSettings(sessionKey = 0) {
|
|||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
setSettings(data);
|
setSettings(data);
|
||||||
setDisplayTimezone(normalizeTimezone(data.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
setDisplayTimezone(normalizeTimezone(data.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE));
|
||||||
|
setChartTimeFormat(normalizeChartTimeFormat(data.chartTimeFormat ?? DEFAULT_CHART_TIME_FORMAT));
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
@@ -238,6 +247,9 @@ export function useAppSettings(sessionKey = 0) {
|
|||||||
if (patch.displayTimezone != null) {
|
if (patch.displayTimezone != null) {
|
||||||
setDisplayTimezone(normalizeTimezone(patch.displayTimezone));
|
setDisplayTimezone(normalizeTimezone(patch.displayTimezone));
|
||||||
}
|
}
|
||||||
|
if (patch.chartTimeFormat != null) {
|
||||||
|
setChartTimeFormat(normalizeChartTimeFormat(patch.chartTimeFormat));
|
||||||
|
}
|
||||||
saveAppSettings(patch).then(updated => {
|
saveAppSettings(patch).then(updated => {
|
||||||
if (updated) {
|
if (updated) {
|
||||||
_cache = { ...(_cache ?? {}), ...updated };
|
_cache = { ...(_cache ?? {}), ...updated };
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useLayoutEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신호등+하단 %/문구(.vtd-sig-light-rail) 높이를 측정해
|
||||||
|
* 이퀄라이저 pane 눈금·막대 높이(--vtd-sig-rail-height)와 동기화
|
||||||
|
*/
|
||||||
|
export function useSignalVisualRailHeight(deps: unknown[] = []) {
|
||||||
|
const visualRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lightRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const visual = visualRef.current;
|
||||||
|
const light = lightRef.current;
|
||||||
|
if (!visual || !light) return;
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
const h = Math.ceil(light.getBoundingClientRect().height);
|
||||||
|
if (h > 0) {
|
||||||
|
visual.style.setProperty('--vtd-sig-rail-height', `${h}px`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sync();
|
||||||
|
const ro = new ResizeObserver(sync);
|
||||||
|
ro.observe(light);
|
||||||
|
window.addEventListener('resize', sync);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener('resize', sync);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
return { visualRef, lightRef };
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* 매매 시그널 알림 목록 행 — 종목×전략 실시간 신호 스냅샷 (가상매매 카드 요약형과 동일 소스)
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { StrategyDto } from '../utils/backendApi';
|
||||||
|
import {
|
||||||
|
fetchLiveSnapshot,
|
||||||
|
type VirtualIndicatorSnapshot,
|
||||||
|
} from './useVirtualIndicatorSnapshots';
|
||||||
|
|
||||||
|
export function useTradeNotificationSignalSnapshot(
|
||||||
|
market: string,
|
||||||
|
strategyId: number | null | undefined,
|
||||||
|
strategy: StrategyDto | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
pollMs = 3000,
|
||||||
|
): { snapshot: VirtualIndicatorSnapshot | undefined; loading: boolean } {
|
||||||
|
const [snapshot, setSnapshot] = useState<VirtualIndicatorSnapshot | undefined>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const emptyRetryRef = useRef(0);
|
||||||
|
const genRef = useRef(0);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!enabled || strategyId == null) {
|
||||||
|
setSnapshot(undefined);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gen = ++genRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
const pinFirst = emptyRetryRef.current === 0;
|
||||||
|
const snap = await fetchLiveSnapshot(market, strategyId, strategy, pinFirst);
|
||||||
|
if (gen !== genRef.current) return;
|
||||||
|
|
||||||
|
if (snap && snap.rows.length > 0) {
|
||||||
|
emptyRetryRef.current = 0;
|
||||||
|
} else {
|
||||||
|
emptyRetryRef.current += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapshot(snap ?? undefined);
|
||||||
|
setLoading(false);
|
||||||
|
}, [market, strategyId, strategy, enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || strategyId == null) {
|
||||||
|
setSnapshot(undefined);
|
||||||
|
setLoading(false);
|
||||||
|
emptyRetryRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyRetryRef.current = 0;
|
||||||
|
void refresh();
|
||||||
|
const id = window.setInterval(() => void refresh(), pollMs);
|
||||||
|
return () => {
|
||||||
|
clearInterval(id);
|
||||||
|
genRef.current += 1;
|
||||||
|
};
|
||||||
|
}, [market, strategyId, enabled, pollMs, refresh]);
|
||||||
|
|
||||||
|
return { snapshot, loading };
|
||||||
|
}
|
||||||
@@ -108,7 +108,7 @@ function staticSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 백엔드가 업비트 REST·WS로 캔들 warm-up 후 Ta4j 평가 */
|
/** 백엔드가 업비트 REST·WS로 캔들 warm-up 후 Ta4j 평가 */
|
||||||
async function fetchLiveSnapshot(
|
export async function fetchLiveSnapshot(
|
||||||
market: string,
|
market: string,
|
||||||
strategyId: number,
|
strategyId: number,
|
||||||
strategy: StrategyDto | undefined,
|
strategy: StrategyDto | undefined,
|
||||||
|
|||||||
@@ -111,6 +111,47 @@
|
|||||||
--vtd-heat-inset: inset 0 1px 4px rgba(0, 0, 0, 0.45);
|
--vtd-heat-inset: inset 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* BuilderPageShell 없이 bps-page--vtd 만 쓰는 화면(매매 시그널 알림 등) */
|
||||||
|
.bps-page--vtd.se-page--dark,
|
||||||
|
.bps-page--vtd.se-page--blue,
|
||||||
|
.bps-page--vtd:not(.se-page--light) {
|
||||||
|
--vtd-sig-gold-text: color-mix(in srgb, var(--se-gold, #e6c200) 90%, #fff);
|
||||||
|
--vtd-sig-panel-bg: color-mix(in srgb, #0d111f 60%, transparent);
|
||||||
|
--vtd-sig-card-gradient: color-mix(in srgb, #1a2035 92%, #000);
|
||||||
|
--vtd-sig-eq-bg: color-mix(in srgb, #000 35%, transparent);
|
||||||
|
--vtd-heat-track-bg: color-mix(in srgb, #060a14 85%, #000);
|
||||||
|
--vtd-heat-label-color: color-mix(in srgb, var(--se-text, var(--text)) 88%, #fff);
|
||||||
|
--vtd-heat-label-shadow: 0 0 6px rgba(0, 0, 0, 0.9), 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||||
|
--vtd-sig-table-bg: color-mix(in srgb, #0d111f 50%, transparent);
|
||||||
|
--vtd-sig-table-head-bg: color-mix(in srgb, var(--se-gold, #e6c200) 6%, transparent);
|
||||||
|
--vtd-cond-item-bg: color-mix(in srgb, var(--bg2, #1a1b26) 70%, #000);
|
||||||
|
--vtd-light-housing-bg: linear-gradient(180deg, #2a2a2a, #1a1a1a);
|
||||||
|
--vtd-light-housing-border: color-mix(in srgb, #555 80%, #888);
|
||||||
|
--vtd-status-warm: #ffe082;
|
||||||
|
--vtd-status-hot: #82b1ff;
|
||||||
|
--vtd-status-cold: #ff8a80;
|
||||||
|
--vtd-heat-inset: inset 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bps-page--vtd.se-page--light {
|
||||||
|
--vtd-sig-gold-text: color-mix(in srgb, var(--se-gold, #f57f17) 88%, #212121);
|
||||||
|
--vtd-sig-panel-bg: color-mix(in srgb, var(--se-gold, #f57f17) 5%, #ffffff);
|
||||||
|
--vtd-sig-card-gradient: color-mix(in srgb, var(--se-gold, #f57f17) 7%, #ffffff);
|
||||||
|
--vtd-sig-eq-bg: color-mix(in srgb, var(--bg4, #f5f5f5) 40%, #ffffff);
|
||||||
|
--vtd-heat-track-bg: color-mix(in srgb, var(--bg4, #f5f5f5) 55%, #ffffff);
|
||||||
|
--vtd-heat-label-color: var(--se-text, var(--text));
|
||||||
|
--vtd-heat-label-shadow: none;
|
||||||
|
--vtd-sig-table-bg: #ffffff;
|
||||||
|
--vtd-sig-table-head-bg: color-mix(in srgb, var(--se-gold, #f57f17) 12%, #f5f5f5);
|
||||||
|
--vtd-cond-item-bg: var(--bg2, #ffffff);
|
||||||
|
--vtd-light-housing-bg: linear-gradient(180deg, #eceff1, #cfd8dc);
|
||||||
|
--vtd-light-housing-border: rgba(0, 0, 0, 0.18);
|
||||||
|
--vtd-status-warm: #e65100;
|
||||||
|
--vtd-status-hot: #1565c0;
|
||||||
|
--vtd-status-cold: #c62828;
|
||||||
|
--vtd-heat-inset: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.se-page--light {
|
.se-page--light {
|
||||||
--se-gold: #f57f17;
|
--se-gold: #f57f17;
|
||||||
--se-success: #2e7d32;
|
--se-success: #2e7d32;
|
||||||
|
|||||||
@@ -1,34 +1,341 @@
|
|||||||
/* 매매 시그널 알림 목록 — 갤러리 행 (요약 카드 + 가로 스크롤 차트) */
|
/* 매매 시그널 알림 목록 — 갤러리 행 (요약 카드 + 가로 스크롤 차트) */
|
||||||
|
|
||||||
|
/* 실시간 WS/STOMP 수신 — 목록 행(.tnl-row) 외곽 아웃라인만 형광 연두 (내부 inset 없음) */
|
||||||
|
.tnl-row.tnl-row--receiving {
|
||||||
|
border-color: #69f0ae !important;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px #69f0ae,
|
||||||
|
0 0 10px 4px #69f0ae66,
|
||||||
|
0 0 22px 8px #b9f6ca33;
|
||||||
|
transition: box-shadow 0.15s ease-out, border-color 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 수신 시 알림 상태 카드(좌측 요약)에는 연두 효과 미적용 */
|
||||||
|
.tnl-row.tnl-row--receiving .tnl-row-gallery-detail .tnl-hscroll-pane,
|
||||||
|
.tnl-row.tnl-row--receiving .tnl-summary-card {
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: var(--se-panel-card-bg, var(--bg2));
|
||||||
|
transition: border-color 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row.tnl-row--receiving .tnl-row-gallery-detail .tnl-hscroll-pane {
|
||||||
|
border-color: var(--se-border, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--bg) 40%, var(--bg2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row.tnl-row--receiving .tnl-summary-card--buy {
|
||||||
|
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row.tnl-row--receiving .tnl-summary-card--sell {
|
||||||
|
border-color: color-mix(in srgb, #ef5350 35%, var(--se-border, var(--border)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row.tnl-row--receiving.tnl-row--selected .tnl-summary-card--selected {
|
||||||
|
border-color: var(--accent, #7aa2f7);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.tnl-row--gallery {
|
.tnl-row--gallery {
|
||||||
display: block;
|
display: block;
|
||||||
|
width: 100%;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-row-gallery {
|
.tnl-row-gallery {
|
||||||
|
--tnl-summary-card-width: 320px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
height: 320px;
|
height: 320px;
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 가상매매 투자대상 카드(vtd)와 동일한 3단 구성 */
|
/* 좌측 알림 요약 카드 — 고정 너비(모든 행 동일) · 우측은 차트 영역이 나머지 공간 사용 */
|
||||||
.tnl-summary-card {
|
.tnl-row-gallery-detail {
|
||||||
flex: 0 0 544px;
|
flex: 0 0 var(--tnl-summary-card-width);
|
||||||
width: 544px;
|
width: var(--tnl-summary-card-width);
|
||||||
|
min-width: var(--tnl-summary-card-width);
|
||||||
|
max-width: var(--tnl-summary-card-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row-gallery-charts {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row-gallery--chart-expanded .tnl-row-gallery-detail {
|
||||||
|
flex: 0 0 var(--tnl-summary-card-width);
|
||||||
|
width: var(--tnl-summary-card-width);
|
||||||
|
min-width: var(--tnl-summary-card-width);
|
||||||
|
max-width: var(--tnl-summary-card-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row-gallery--chart-expanded .tnl-row-gallery-charts {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 가로 스크롤 + 우측 세로 레일 버튼 */
|
||||||
|
.tnl-hscroll-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
align-self: flex-start;
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--se-border, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--bg) 40%, var(--bg2));
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-hscroll-pane__viewport {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in srgb, var(--text3) 35%, transparent) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-hscroll-pane__viewport::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-hscroll-pane__viewport::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--text3) 35%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-hscroll-pane__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row-gallery-charts .tnl-hscroll-pane__content {
|
||||||
|
min-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row-gallery-charts .tnl-hscroll-pane__viewport {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-hscroll-pane__rail {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
width: 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border-left: 1px solid var(--se-border, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--bg3, var(--bg2)) 85%, transparent);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-hscroll-pane__rail-btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 96px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 28%, var(--se-border, var(--border)));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--bg2) 90%, transparent);
|
||||||
|
color: var(--accent, #3f7ef5);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, opacity 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-hscroll-pane__rail-btn:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--accent, #3f7ef5) 16%, var(--bg2));
|
||||||
|
border-color: var(--accent, #3f7ef5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-hscroll-pane__rail-btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-hscroll-pane__rail-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
color: var(--text3);
|
||||||
|
border-color: var(--se-border, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 좌측 알림 상세(매매 시그널 요약 카드) */
|
||||||
|
.tnl-row-gallery-detail .tnl-hscroll-pane__viewport {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row-gallery-detail .tnl-hscroll-pane__content {
|
||||||
|
min-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row-gallery-detail .tnl-hscroll-pane {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row-gallery-detail .tnl-summary-card {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 신호상세 — 가상매매 vtd-card 슬롯 (이퀄라이저·히트맵·신호등 최소 너비 확보) */
|
||||||
|
.tnl-signal-slot {
|
||||||
|
flex: 1.35 1 300px;
|
||||||
|
width: auto;
|
||||||
|
min-width: min(100%, 300px);
|
||||||
|
max-width: 42%;
|
||||||
|
height: 280px;
|
||||||
|
max-height: 280px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-signal-slot .tnl-signal-summary--embedded-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-signal-slot .tnl-signal-summary--embedded-wrap > .vtd-card {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
align-self: stretch;
|
||||||
|
overflow: visible;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-signal-slot .tnl-signal-summary--embedded-wrap > .vtd-card > .vtd-sig-panel-wrap--summary {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-signal-slot .tnl-signal-summary--embedded-wrap .vtd-sig-panel--summary {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-signal-slot .tnl-signal-summary--embedded-wrap .vtd-sig-visual {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 가상매매와 동일 vtd-sig-* (strategyEditorTheme + virtualTradingDashboard) */
|
||||||
|
|
||||||
|
.tnl-signal-slot .tnl-signal-summary--embedded-wrap > .vtd-card > .vtd-card-chart-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-signal-slot .tnl-signal-summary--embedded-wrap .vtd-card-foot-select:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
cursor: default;
|
||||||
|
color: var(--se-text);
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 신호 상세 헤더 — 우측 현재가·등락·실시간 (아이콘/토글 버튼 없음) */
|
||||||
|
.tnl-signal-slot .vtd-card--head-inline-quote .vtd-card-head {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-signal-slot .vtd-card-head-quote {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-signal-slot .vtd-card-head-quote .vtd-target-quote--compact {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-signal-slot .vtd-card-head-quote .vtd-target-price {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-signal-slot .vtd-card-head-quote .vtd-target-change {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-signal-slot .vtd-card-head-quote .vtd-live-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-charts-track > .tnl-signal-slot {
|
||||||
|
flex: 1.35 1 300px;
|
||||||
|
min-width: min(100%, 300px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 가상매매 투자대상 카드(vtd)와 동일한 3단 구성 */
|
||||||
|
.tnl-summary-card {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
align-self: stretch;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border: 1px solid color-mix(in srgb, var(--accent, #7aa2f7) 28%, var(--se-border, var(--border)));
|
border: 1px solid color-mix(in srgb, var(--accent, #7aa2f7) 28%, var(--se-border, var(--border)));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--se-panel-card-bg, var(--bg2));
|
background: var(--se-panel-card-bg, var(--bg2));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row-gallery-detail .tnl-summary-card {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-summary-card--buy {
|
.tnl-summary-card--buy {
|
||||||
@@ -71,8 +378,10 @@
|
|||||||
|
|
||||||
.tnl-summary-title {
|
.tnl-summary-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 4px;
|
align-items: baseline;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -82,6 +391,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -91,6 +401,8 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text3, var(--se-text-muted));
|
color: var(--text3, var(--se-text-muted));
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -270,25 +582,23 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-row-gallery--chart-expanded .tnl-charts-scroll {
|
.tnl-charts-expanded-inner {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tnl-charts-expanded {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
height: 300px;
|
|
||||||
max-height: 300px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-radius: 12px;
|
flex: 1 1 auto;
|
||||||
border: 1px solid var(--se-border, var(--border));
|
min-width: 100%;
|
||||||
background: color-mix(in srgb, var(--bg) 40%, var(--bg2));
|
width: 100%;
|
||||||
overflow: hidden;
|
height: 100%;
|
||||||
|
padding: 0 4px 4px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-charts-expanded .tnl-chart-card--expanded {
|
.tnl-row-gallery-charts--expanded .tnl-hscroll-pane__content {
|
||||||
|
min-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-charts-expanded-inner .tnl-chart-card--expanded {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -296,48 +606,33 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 4px 4px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-charts-scroll {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
height: 300px;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
overscroll-behavior-x: contain;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--se-border, var(--border));
|
|
||||||
background: color-mix(in srgb, var(--bg) 40%, var(--bg2));
|
|
||||||
}
|
|
||||||
|
|
||||||
.tnl-charts-scroll::-webkit-scrollbar {
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tnl-charts-scroll::-webkit-scrollbar-thumb {
|
|
||||||
background: color-mix(in srgb, var(--text3) 35%, transparent);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tnl-charts-track {
|
.tnl-charts-track {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: max-content;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-charts-track > .tnl-chart-card {
|
||||||
|
flex: 1 1 0;
|
||||||
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-chart-card {
|
.tnl-chart-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 300px;
|
flex: 1 1 0;
|
||||||
width: 300px;
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
height: 280px;
|
height: 280px;
|
||||||
max-height: 280px;
|
max-height: 280px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -494,35 +789,20 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 헤더 — 제목(좌) · 목록/그리드 전환(우) */
|
/* 툴바 — 좌: 필터·액션 · 우: 목록/그리드·배치·삭제 */
|
||||||
.tnl-header-row {
|
.tnl-page--with-right .tnl-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-header-intro {
|
.tnl-toolbar-end {
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tnl-header-intro .tnl-title {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tnl-header-intro .tnl-sub {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tnl-header-actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-top: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-layout-toggle {
|
.tnl-layout-toggle {
|
||||||
@@ -735,30 +1015,70 @@ ul.tnl-list.tnl-list--grid {
|
|||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-row--gallery.tnl-row--selected .tnl-summary-card,
|
.tnl-row--gallery.tnl-row--selected .tnl-hscroll-pane,
|
||||||
.tnl-row--gallery.tnl-row--selected .tnl-charts-scroll,
|
.tnl-row--gallery.tnl-row--selected .tnl-summary-card--selected {
|
||||||
.tnl-row--gallery.tnl-row--selected .tnl-charts-expanded {
|
|
||||||
border-color: var(--accent, #7aa2f7);
|
border-color: var(--accent, #7aa2f7);
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tnl-list--gallery .tnl-row--gallery {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
@container tnl-main (max-width: 900px) {
|
@container tnl-main (max-width: 900px) {
|
||||||
.tnl-row-gallery {
|
.tnl-row-gallery {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-summary-card {
|
.tnl-row-gallery {
|
||||||
flex: 0 0 auto;
|
--tnl-summary-card-width: min(100%, 320px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row-gallery-detail,
|
||||||
|
.tnl-row-gallery--chart-expanded .tnl-row-gallery-detail {
|
||||||
|
flex: 0 0 var(--tnl-summary-card-width);
|
||||||
|
width: var(--tnl-summary-card-width);
|
||||||
|
min-width: var(--tnl-summary-card-width);
|
||||||
|
max-width: var(--tnl-summary-card-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-row-gallery-charts,
|
||||||
|
.tnl-row-gallery--chart-expanded .tnl-row-gallery-charts {
|
||||||
|
flex: 1 1 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: none;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tnl-charts-scroll,
|
.tnl-hscroll-pane {
|
||||||
.tnl-charts-expanded {
|
|
||||||
height: 300px;
|
height: 300px;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tnl-row-gallery-charts .tnl-hscroll-pane__viewport {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-summary-card {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-charts-track {
|
||||||
|
min-width: max-content;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tnl-charts-track > .tnl-chart-card,
|
||||||
|
.tnl-charts-track > .tnl-signal-slot {
|
||||||
|
flex: 0 0 min(300px, 88vw);
|
||||||
|
width: min(300px, 88vw);
|
||||||
|
min-width: min(300px, 88vw);
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1279,6 +1279,19 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vtd-card-head-quote {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vtd-card--head-inline-quote .vtd-card-head-quote .vtd-target-quote--compact {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 카드 내 신호 / 차트 전환 */
|
/* 카드 내 신호 / 차트 전환 */
|
||||||
.vtd-card-mode-toggle {
|
.vtd-card-mode-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1658,6 +1671,15 @@
|
|||||||
max-width: 96px;
|
max-width: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vtd-card-foot-select--readonly {
|
||||||
|
display: inline-block;
|
||||||
|
cursor: default;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.vtd-sig-panel-title {
|
.vtd-sig-panel-title {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -1668,63 +1690,121 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vtd-sig-visual {
|
.vtd-sig-visual {
|
||||||
|
--vtd-sig-rail-height: 164px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: start;
|
align-items: center;
|
||||||
gap: 12px 14px;
|
gap: 10px 12px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid color-mix(in srgb, var(--se-gold) 30%, var(--se-border));
|
border: 1px solid color-mix(in srgb, var(--vtd-sig-gold-text, var(--se-gold, #e6c200)) 38%, var(--se-border, var(--border)));
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--vtd-sig-panel-bg);
|
background: var(--vtd-sig-panel-bg, color-mix(in srgb, #0d111f 60%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 이퀄라이저 · 지표일치율 · 신호등 — 열별 pane 아웃라인 (가상매매·알림 동일) */
|
||||||
|
.vtd-sig-visual-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vtd-sig-gold-text, var(--se-gold, #e6c200)) 32%, var(--se-border, var(--border)));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--vtd-sig-panel-bg, rgba(13, 17, 31, 0.6)) 72%, transparent);
|
||||||
|
box-sizing: border-box;
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vtd-sig-gold-text, var(--se-gold, #e6c200)) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vtd-sig-visual-eq {
|
.vtd-sig-visual-eq {
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
justify-content: stretch;
|
||||||
|
align-self: center;
|
||||||
|
height: var(--vtd-sig-rail-height);
|
||||||
|
min-height: var(--vtd-sig-rail-height);
|
||||||
|
max-height: var(--vtd-sig-rail-height);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 세그먼트 이퀄라이저 */
|
.vtd-sig-visual-heat {
|
||||||
.vtd-sig-eq {
|
justify-content: center;
|
||||||
display: flex;
|
overflow: hidden;
|
||||||
gap: 8px;
|
align-self: center;
|
||||||
align-items: stretch;
|
min-height: var(--vtd-sig-rail-height);
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vtd-sig-eq-column {
|
.vtd-sig-visual-heat .vtd-heat-list {
|
||||||
|
width: 100%;
|
||||||
|
max-height: min(188px, calc(var(--vtd-sig-rail-height) + 48px));
|
||||||
|
margin: auto 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vtd-sig-visual-light {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vtd-sig-light-rail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 세그먼트 이퀄라이저 — 신호등+하단문구 높이와 눈금·막대 동기화 */
|
||||||
|
.vtd-sig-visual-eq .vtd-sig-eq {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 40px;
|
min-height: 0;
|
||||||
max-width: 52px;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vtd-sig-eq-scale {
|
.vtd-sig-eq-scale {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 100%;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted);
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vtd-sig-eq-tick {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.vtd-sig-eq-bar-wrap {
|
.vtd-sig-eq-bar-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 40px;
|
||||||
|
max-width: 52px;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vtd-sig-eq-bar {
|
.vtd-sig-eq-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
height: 180px;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border: 1px solid color-mix(in srgb, var(--se-gold) 40%, var(--se-border));
|
border: 1px solid color-mix(in srgb, var(--se-gold) 40%, var(--se-border));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--vtd-sig-eq-bg);
|
background: var(--vtd-sig-eq-bg);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vtd-sig-eq-seg {
|
.vtd-sig-eq-seg {
|
||||||
@@ -1756,7 +1836,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vtd-sig-eq-badge {
|
.vtd-sig-eq-badge {
|
||||||
margin-top: 6px;
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
transform: translateX(-50%);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
@@ -1765,6 +1848,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent);
|
text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Proximity Heatmap Bar — 이퀄라이저 ↔ 신호등 사이 */
|
/* Proximity Heatmap Bar — 이퀄라이저 ↔ 신호등 사이 */
|
||||||
@@ -1941,8 +2025,8 @@
|
|||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vtd-sig-panel--summary .vtd-heat-list {
|
.vtd-sig-panel--summary .vtd-sig-visual-heat .vtd-heat-list {
|
||||||
max-height: 160px;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* legacy 조건 목록 (상세 테이블 STATUS 열) */
|
/* legacy 조건 목록 (상세 테이블 STATUS 열) */
|
||||||
@@ -2047,13 +2131,18 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vtd-sig-visual-light .vtd-sig-light--card-right {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.vtd-sig-light--card-right {
|
.vtd-sig-light--card-right {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 4px 0 2px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vtd-sig-light-housing {
|
.vtd-sig-light-housing {
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
|
|||||||
import { resolveBbBandBackground } from './bollingerConfig';
|
import { resolveBbBandBackground } from './bollingerConfig';
|
||||||
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
|
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
|
||||||
import { formatChartAxisPrice } from './dataGenerator';
|
import { formatChartAxisPrice } from './dataGenerator';
|
||||||
import { formatLwcTime, formatLwcTickMark, DEFAULT_DISPLAY_TIMEZONE } from './timezone';
|
import {
|
||||||
|
DEFAULT_CHART_TIME_FORMAT,
|
||||||
|
formatUnixWithChartPattern,
|
||||||
|
formatLwcTimeWithPattern,
|
||||||
|
formatLwcTickMarkWithPattern,
|
||||||
|
normalizeChartTimeFormat,
|
||||||
|
} from './chartTimeFormat';
|
||||||
|
import { DEFAULT_DISPLAY_TIMEZONE } from './timezone';
|
||||||
import { sortIndicatorsForPaneLoad } from './indicatorPaneMerge';
|
import { sortIndicatorsForPaneLoad } from './indicatorPaneMerge';
|
||||||
import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry';
|
import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry';
|
||||||
import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin';
|
import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin';
|
||||||
@@ -203,6 +210,7 @@ export class ChartManager {
|
|||||||
private currentChartType: ChartType = 'candlestick';
|
private currentChartType: ChartType = 'candlestick';
|
||||||
private displayTimezone = DEFAULT_DISPLAY_TIMEZONE;
|
private displayTimezone = DEFAULT_DISPLAY_TIMEZONE;
|
||||||
private displayTimeframe: Timeframe = '1D';
|
private displayTimeframe: Timeframe = '1D';
|
||||||
|
private chartTimeFormat = DEFAULT_CHART_TIME_FORMAT;
|
||||||
private mainMarkersPlugin: ISeriesMarkersPluginApi<Time> | null = null;
|
private mainMarkersPlugin: ISeriesMarkersPluginApi<Time> | null = null;
|
||||||
private patternMarkers: Array<{ id: string; markers: MarkerData[] }> = [];
|
private patternMarkers: Array<{ id: string; markers: MarkerData[] }> = [];
|
||||||
/** 백테스팅 매수/매도 시그널 마커 */
|
/** 백테스팅 매수/매도 시그널 마커 */
|
||||||
@@ -461,29 +469,37 @@ export class ChartManager {
|
|||||||
|
|
||||||
// ─── Display timezone ───────────────────────────────────────────────────
|
// ─── Display timezone ───────────────────────────────────────────────────
|
||||||
private _tickMarkFormatter(): TickMarkFormatter {
|
private _tickMarkFormatter(): TickMarkFormatter {
|
||||||
|
const tf = this.displayTimeframe;
|
||||||
|
const tz = this.displayTimezone;
|
||||||
|
const fmt = this.chartTimeFormat;
|
||||||
return (time, tickMarkType) =>
|
return (time, tickMarkType) =>
|
||||||
formatLwcTickMark(
|
formatLwcTickMarkWithPattern(time as Time, tickMarkType, tf, tz, fmt);
|
||||||
time as Time,
|
|
||||||
tickMarkType,
|
|
||||||
this.displayTimeframe,
|
|
||||||
this.displayTimezone,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _applyDisplayTimezoneOptions(): void {
|
private _applyDisplayTimezoneOptions(): void {
|
||||||
const tf = this.displayTimeframe;
|
const tf = this.displayTimeframe;
|
||||||
const tz = this.displayTimezone;
|
const tz = this.displayTimezone;
|
||||||
|
const fmt = this.chartTimeFormat;
|
||||||
const timeFormatter = (time: Time) =>
|
const timeFormatter = (time: Time) =>
|
||||||
formatLwcTime(time as number | { year: number; month: number; day: number }, tf, tz);
|
formatLwcTimeWithPattern(
|
||||||
|
time as number | { year: number; month: number; day: number },
|
||||||
|
tf,
|
||||||
|
tz,
|
||||||
|
fmt,
|
||||||
|
);
|
||||||
this.chart.applyOptions({
|
this.chart.applyOptions({
|
||||||
localization: { timeFormatter, priceFormatter: formatChartAxisPrice },
|
localization: { timeFormatter, priceFormatter: formatChartAxisPrice },
|
||||||
timeScale: { tickMarkFormatter: this._tickMarkFormatter() },
|
timeScale: { tickMarkFormatter: this._tickMarkFormatter() },
|
||||||
});
|
});
|
||||||
|
this._notifyPaneLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
setDisplayTimezone(tz: string, timeframe?: Timeframe): void {
|
setDisplayTimezone(tz: string, timeframe?: Timeframe, chartTimeFormat?: string): void {
|
||||||
this.displayTimezone = tz || DEFAULT_DISPLAY_TIMEZONE;
|
this.displayTimezone = tz || DEFAULT_DISPLAY_TIMEZONE;
|
||||||
if (timeframe) this.displayTimeframe = timeframe;
|
if (timeframe) this.displayTimeframe = timeframe;
|
||||||
|
if (chartTimeFormat != null) {
|
||||||
|
this.chartTimeFormat = normalizeChartTimeFormat(chartTimeFormat);
|
||||||
|
}
|
||||||
this._applyDisplayTimezoneOptions();
|
this._applyDisplayTimezoneOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,12 +821,27 @@ export class ChartManager {
|
|||||||
/** 파라미터 변경된 지표만 제거 후 재추가 (전체 보조지표 재로드 회피) */
|
/** 파라미터 변경된 지표만 제거 후 재추가 (전체 보조지표 재로드 회피) */
|
||||||
async refreshIndicators(configs: IndicatorConfig[]): Promise<void> {
|
async refreshIndicators(configs: IndicatorConfig[]): Promise<void> {
|
||||||
if (configs.length === 0) return;
|
if (configs.length === 0) return;
|
||||||
|
this.cancelPendingIndicatorUpdates();
|
||||||
|
const loadGen = ++this._dataGeneration;
|
||||||
|
|
||||||
let any = false;
|
let any = false;
|
||||||
for (const c of configs) {
|
for (const c of configs) {
|
||||||
if (this._detachIndicatorEntry(c.id)) any = true;
|
if (this._detachIndicatorEntry(c.id)) any = true;
|
||||||
}
|
}
|
||||||
if (any) this._trimTrailingEmptySubPanes();
|
if (any) this._trimTrailingEmptySubPanes();
|
||||||
await this.addIndicatorsBatch(configs);
|
if (this._indicatorLoadStale(loadGen)) return;
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
if (this._indicatorLoadStale(loadGen)) break;
|
||||||
|
await this.addIndicator(config, { skipLayout: true });
|
||||||
|
}
|
||||||
|
if (this._indicatorLoadStale(loadGen)) return;
|
||||||
|
|
||||||
|
this._removeOrphanSubPanes();
|
||||||
|
if (this._activeIndicatorPaneIndices().size > 0) {
|
||||||
|
this.resetPaneHeights(this._lastLayoutAvailableHeight);
|
||||||
|
this._notifyPaneLayout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
|
/** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
|
||||||
@@ -2437,6 +2468,76 @@ export class ChartManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 캔들 pane(0) 플롯 영역 너비 (우측 가격축 제외) */
|
||||||
|
private _candlePlotWidth(): number {
|
||||||
|
try {
|
||||||
|
const ps = this.mainSeries?.priceScale() ?? this.chart.priceScale('right', 0);
|
||||||
|
const scaleW = ps.width();
|
||||||
|
return Math.max(40, this.container.clientWidth - scaleW);
|
||||||
|
} catch {
|
||||||
|
return Math.max(40, this.container.clientWidth - 56);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캔들 pane 하단 시간축 오버레이 — 거래량·보조지표가 있을 때 캔들 영역 바로 아래에도 시각 표시.
|
||||||
|
*/
|
||||||
|
getCandlePaneTimeAxisOverlay(): {
|
||||||
|
topY: number;
|
||||||
|
height: number;
|
||||||
|
plotWidth: number;
|
||||||
|
labels: Array<{ x: number; text: string }>;
|
||||||
|
} | null {
|
||||||
|
if (!this.mainSeries || this.rawBars.length < 2) return null;
|
||||||
|
|
||||||
|
const layouts = this.getPaneLayouts();
|
||||||
|
const hasLowerPane = layouts.some(l => l.paneIndex > 0 && l.height > 8);
|
||||||
|
if (!hasLowerPane) return null;
|
||||||
|
|
||||||
|
const main = layouts.find(l => l.paneIndex === 0);
|
||||||
|
if (!main || main.height < 48) return null;
|
||||||
|
|
||||||
|
const AXIS_H = 22;
|
||||||
|
const ts = this.chart.timeScale();
|
||||||
|
const lr = ts.getVisibleLogicalRange();
|
||||||
|
if (!lr) return null;
|
||||||
|
|
||||||
|
const plotWidth = this._candlePlotWidth();
|
||||||
|
const from = Math.max(0, Math.ceil(lr.from));
|
||||||
|
const to = Math.min(this.rawBars.length - 1, Math.floor(lr.to));
|
||||||
|
if (to <= from) return null;
|
||||||
|
|
||||||
|
const span = to - from;
|
||||||
|
const targetCount = Math.max(4, Math.min(12, Math.floor(plotWidth / 76)));
|
||||||
|
const step = Math.max(1, Math.ceil(span / targetCount));
|
||||||
|
|
||||||
|
const labels: Array<{ x: number; text: string }> = [];
|
||||||
|
const fmt = this.chartTimeFormat;
|
||||||
|
const tz = this.displayTimezone;
|
||||||
|
|
||||||
|
for (let i = from; i <= to; i += step) {
|
||||||
|
const bar = this.rawBars[i];
|
||||||
|
if (!bar?.time) continue;
|
||||||
|
const coord = ts.timeToCoordinate(bar.time as Time);
|
||||||
|
if (coord == null) continue;
|
||||||
|
const x = Number(coord);
|
||||||
|
if (!Number.isFinite(x) || x < 12 || x > plotWidth - 12) continue;
|
||||||
|
labels.push({
|
||||||
|
x,
|
||||||
|
text: formatUnixWithChartPattern(bar.time, fmt, tz),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labels.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
topY: main.topY + main.height - AXIS_H,
|
||||||
|
height: AXIS_H,
|
||||||
|
plotWidth,
|
||||||
|
labels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 하단 시간축 영역 클릭 여부 */
|
/** 하단 시간축 영역 클릭 여부 */
|
||||||
isOnTimeAxis(chartY: number): boolean {
|
isOnTimeAxis(chartY: number): boolean {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
|
|||||||
P('메뉴-백테스팅-분석 차트', 'analysis'),
|
P('메뉴-백테스팅-분석 차트', 'analysis'),
|
||||||
|
|
||||||
// ── 상단 메뉴바 기타 ──
|
// ── 상단 메뉴바 기타 ──
|
||||||
P('메뉴-알림 목록', 'notifications', '시그널목록'),
|
P('메뉴-알림목록', 'notifications', '알림목록'),
|
||||||
P('메뉴-테마 전환', 'theme', '다크', '라이트'),
|
P('메뉴-테마 전환', 'theme', '다크', '라이트'),
|
||||||
P('메뉴-로그인', 'login', 'auth'),
|
P('메뉴-로그인', 'login', 'auth'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -444,6 +444,8 @@ export interface AppSettingsDto {
|
|||||||
fcmPushEnabled?: boolean;
|
fcmPushEnabled?: boolean;
|
||||||
/** 차트·UI 표시 시간대 (IANA, 예: Asia/Seoul) */
|
/** 차트·UI 표시 시간대 (IANA, 예: Asia/Seoul) */
|
||||||
displayTimezone?: string;
|
displayTimezone?: string;
|
||||||
|
/** 차트 하단 시간축 포맷 (예: yyyy-MM-dd HH:mm) */
|
||||||
|
chartTimeFormat?: string;
|
||||||
/** 추세검색 기본 설정 */
|
/** 추세검색 기본 설정 */
|
||||||
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
|
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
|
||||||
/** UI 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
|
/** UI 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* 차트 하단 시간축·크로스헤어 시간 표시 포맷 (앱 설정 chartTimeFormat).
|
||||||
|
*/
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import { TickMarkType, type Time } from 'lightweight-charts';
|
||||||
|
import type { Timeframe } from '../types';
|
||||||
|
import { getDisplayTimezone, normalizeTimezone } from './timezone';
|
||||||
|
|
||||||
|
export const DEFAULT_CHART_TIME_FORMAT = 'MM-dd HH:mm';
|
||||||
|
|
||||||
|
export interface ChartTimeFormatPreset {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 통상적인 차트 시간축 프리셋 */
|
||||||
|
export const CHART_TIME_FORMAT_PRESETS: ChartTimeFormatPreset[] = [
|
||||||
|
{ id: 'yyyy-MM-dd HH:mm:ss', label: 'yyyy-MM-dd HH:mm:ss' },
|
||||||
|
{ id: 'yyyy-MM-dd HH:mm', label: 'yyyy-MM-dd HH:mm' },
|
||||||
|
{ id: 'yyyy-MM-dd', label: 'yyyy-MM-dd' },
|
||||||
|
{ id: 'MM-dd HH:mm:ss', label: 'MM-dd HH:mm:ss' },
|
||||||
|
{ id: 'MM-dd HH:mm', label: 'MM-dd HH:mm' },
|
||||||
|
{ id: 'dd/MM HH:mm', label: 'dd/MM HH:mm' },
|
||||||
|
{ id: 'dd-MM HH:mm', label: 'dd-MM HH:mm' },
|
||||||
|
{ id: 'HH:mm:ss', label: 'HH:mm:ss' },
|
||||||
|
{ id: 'HH:mm', label: 'HH:mm' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRESET_IDS = new Set(CHART_TIME_FORMAT_PRESETS.map(p => p.id));
|
||||||
|
|
||||||
|
let _format = DEFAULT_CHART_TIME_FORMAT;
|
||||||
|
const _listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function getChartTimeFormat(): string {
|
||||||
|
return _format;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setChartTimeFormat(format: string): void {
|
||||||
|
const next = normalizeChartTimeFormat(format);
|
||||||
|
if (_format === next) return;
|
||||||
|
_format = next;
|
||||||
|
_listeners.forEach(l => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeChartTimeFormat(cb: () => void): () => void {
|
||||||
|
_listeners.add(cb);
|
||||||
|
return () => _listeners.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChartTimeFormat(): string {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeChartTimeFormat,
|
||||||
|
getChartTimeFormat,
|
||||||
|
() => DEFAULT_CHART_TIME_FORMAT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 허용 토큰: yyyy yy MM dd HH mm ss */
|
||||||
|
export function normalizeChartTimeFormat(format: string): string {
|
||||||
|
const t = (format || '').trim();
|
||||||
|
if (!t) return DEFAULT_CHART_TIME_FORMAT;
|
||||||
|
if (!/[yMdHms]/.test(t)) return DEFAULT_CHART_TIME_FORMAT;
|
||||||
|
return t.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPresetChartTimeFormat(format: string): boolean {
|
||||||
|
return PRESET_IDS.has(normalizeChartTimeFormat(format));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZonedParts {
|
||||||
|
yyyy: string;
|
||||||
|
yy: string;
|
||||||
|
MM: string;
|
||||||
|
dd: string;
|
||||||
|
HH: string;
|
||||||
|
mm: string;
|
||||||
|
ss: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getZonedParts(unixSec: number, tz: string): ZonedParts {
|
||||||
|
const d = new Date(unixSec * 1000);
|
||||||
|
const zone = normalizeTimezone(tz);
|
||||||
|
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: zone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(d);
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const p of parts) {
|
||||||
|
if (p.type !== 'literal') map[p.type] = p.value;
|
||||||
|
}
|
||||||
|
const year = map.year ?? '';
|
||||||
|
return {
|
||||||
|
yyyy: year,
|
||||||
|
yy: year.slice(-2),
|
||||||
|
MM: map.month ?? '',
|
||||||
|
dd: map.day ?? '',
|
||||||
|
HH: map.hour ?? '',
|
||||||
|
mm: map.minute ?? '',
|
||||||
|
ss: map.second ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_ORDER = ['yyyy', 'yy', 'MM', 'dd', 'HH', 'mm', 'ss'] as const;
|
||||||
|
|
||||||
|
export function applyChartTimePattern(pattern: string, parts: ZonedParts): string {
|
||||||
|
let out = normalizeChartTimeFormat(pattern);
|
||||||
|
for (const tok of TOKEN_ORDER) {
|
||||||
|
out = out.split(tok).join(parts[tok]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patternHasDate(pattern: string): boolean {
|
||||||
|
return /yyyy|yy|MM|dd/.test(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patternHasTime(pattern: string): boolean {
|
||||||
|
return /HH|mm|ss/.test(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 패턴에서 날짜·시간 부분 분리 (눈금 타입별 표시용) */
|
||||||
|
export function splitChartTimePattern(pattern: string): { date: string; time: string } {
|
||||||
|
const p = normalizeChartTimeFormat(pattern);
|
||||||
|
if (!patternHasTime(p)) return { date: p, time: '' };
|
||||||
|
if (!patternHasDate(p)) return { date: '', time: p };
|
||||||
|
|
||||||
|
const timeIdx = p.search(/HH|mm|ss/);
|
||||||
|
if (timeIdx < 0) return { date: p, time: '' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: p.slice(0, timeIdx).trim(),
|
||||||
|
time: p.slice(timeIdx).trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDailyTimeframe(tf?: string): boolean {
|
||||||
|
return tf === '1D' || tf === '1W' || tf === '1M';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** unix 초 → 사용자 지정 포맷 */
|
||||||
|
export function formatUnixWithChartPattern(
|
||||||
|
unixSec: number,
|
||||||
|
pattern?: string,
|
||||||
|
tz?: string,
|
||||||
|
): string {
|
||||||
|
if (!unixSec) return '';
|
||||||
|
const zone = normalizeTimezone(tz ?? getDisplayTimezone());
|
||||||
|
const parts = getZonedParts(unixSec, zone);
|
||||||
|
return applyChartTimePattern(pattern ?? _format, parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LWC 크로스헤어·timeFormatter */
|
||||||
|
export function formatLwcTimeWithPattern(
|
||||||
|
time: number | { year: number; month: number; day: number },
|
||||||
|
timeframe?: Timeframe,
|
||||||
|
tz?: string,
|
||||||
|
pattern?: string,
|
||||||
|
): string {
|
||||||
|
const fmt = pattern ?? _format;
|
||||||
|
const zone = normalizeTimezone(tz ?? getDisplayTimezone());
|
||||||
|
if (typeof time === 'number') {
|
||||||
|
if (isDailyTimeframe(timeframe) && !patternHasTime(fmt)) {
|
||||||
|
return formatUnixWithChartPattern(time, fmt, zone);
|
||||||
|
}
|
||||||
|
return formatUnixWithChartPattern(time, fmt, zone);
|
||||||
|
}
|
||||||
|
const unix = Math.floor(Date.UTC(time.year, time.month - 1, time.day) / 1000);
|
||||||
|
return formatUnixWithChartPattern(unix, fmt, zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LWC X축 눈금 */
|
||||||
|
export function formatLwcTickMarkWithPattern(
|
||||||
|
time: Time,
|
||||||
|
tickMarkType: TickMarkType,
|
||||||
|
timeframe?: Timeframe,
|
||||||
|
tz?: string,
|
||||||
|
pattern?: string,
|
||||||
|
): string {
|
||||||
|
const fmt = normalizeChartTimeFormat(pattern ?? _format);
|
||||||
|
const zone = normalizeTimezone(tz ?? getDisplayTimezone());
|
||||||
|
const unixSec = typeof time === 'number'
|
||||||
|
? time
|
||||||
|
: time && typeof time === 'object' && 'year' in time
|
||||||
|
? Math.floor(Date.UTC(time.year, time.month - 1, time.day) / 1000)
|
||||||
|
: null;
|
||||||
|
if (unixSec == null) return '';
|
||||||
|
const parts = getZonedParts(unixSec, zone);
|
||||||
|
const { date: datePat, time: timePat } = splitChartTimePattern(fmt);
|
||||||
|
|
||||||
|
switch (tickMarkType) {
|
||||||
|
case TickMarkType.Year:
|
||||||
|
return parts.yyyy || applyChartTimePattern('yyyy', parts);
|
||||||
|
case TickMarkType.Month:
|
||||||
|
if (datePat.includes('MM')) return applyChartTimePattern(datePat.includes('yyyy') ? 'yyyy-MM' : 'MM', parts);
|
||||||
|
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, month: 'short' }).format(new Date(unixSec * 1000));
|
||||||
|
case TickMarkType.DayOfMonth:
|
||||||
|
if (isDailyTimeframe(timeframe) || !patternHasTime(fmt)) {
|
||||||
|
return datePat ? applyChartTimePattern(datePat, parts) : parts.dd;
|
||||||
|
}
|
||||||
|
return datePat ? applyChartTimePattern(datePat, parts) : parts.dd;
|
||||||
|
case TickMarkType.Time:
|
||||||
|
return timePat ? applyChartTimePattern(timePat.replace(/ss/g, ''), parts) : `${parts.HH}:${parts.mm}`;
|
||||||
|
case TickMarkType.TimeWithSeconds:
|
||||||
|
return timePat ? applyChartTimePattern(timePat, parts) : `${parts.HH}:${parts.mm}:${parts.ss}`;
|
||||||
|
default:
|
||||||
|
return formatUnixWithChartPattern(unixSec, fmt, zone);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** 차트 pane에서 지표 설정 저장 직후 — App 전역 기본값 병합 1회 스킵 */
|
||||||
|
let skipNextChartDefaultsSync = false;
|
||||||
|
|
||||||
|
export function markChartIndicatorSaveCommitted(): void {
|
||||||
|
skipNextChartDefaultsSync = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeSkipNextChartDefaultsSync(): boolean {
|
||||||
|
if (!skipNextChartDefaultsSync) return false;
|
||||||
|
skipNextChartDefaultsSync = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
|
|||||||
| 'paper' | 'trend-search' | 'alert' | 'network' | 'admin';
|
| 'paper' | 'trend-search' | 'alert' | 'network' | 'admin';
|
||||||
|
|
||||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||||
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings', 'verification-board',
|
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'notifications', 'settings', 'verification-board',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||||
@@ -36,7 +36,7 @@ export const MENU_LABELS: Record<string, string> = {
|
|||||||
strategy: '투자전략',
|
strategy: '투자전략',
|
||||||
'strategy-editor': '전략편집기',
|
'strategy-editor': '전략편집기',
|
||||||
backtest: '백테스팅',
|
backtest: '백테스팅',
|
||||||
notifications: '알림',
|
notifications: '알림목록',
|
||||||
settings: '설정',
|
settings: '설정',
|
||||||
settings_general: '설정 · 일반',
|
settings_general: '설정 · 일반',
|
||||||
settings_chart: '설정 · 차트',
|
settings_chart: '설정 · 차트',
|
||||||
|
|||||||
@@ -276,7 +276,8 @@ function wrapTimeframes(candleTypes: string[], root: LogicNode): LogicNode {
|
|||||||
|
|
||||||
/** 편집기 상태 → 저장/평가용 DSL (TIMEFRAME 래핑) */
|
/** 편집기 상태 → 저장/평가용 DSL (TIMEFRAME 래핑) */
|
||||||
export function encodeConditionForSave(state: EditorConditionState): LogicNode | null {
|
export function encodeConditionForSave(state: EditorConditionState): LogicNode | null {
|
||||||
const branches = collectEditorBranches(state).filter(
|
const synced = syncEditorStateCandleTypesForSave(state);
|
||||||
|
const branches = collectEditorBranches(synced).filter(
|
||||||
(b): b is ConditionBranch & { root: LogicNode } => b.root != null,
|
(b): b is ConditionBranch & { root: LogicNode } => b.root != null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -414,6 +415,15 @@ export function syncBuySellPrimaryStartCandleTypes(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 저장 직전 — 각 START 분봉을 조건 트리 left/rightCandleType 에 반영 */
|
||||||
|
export function syncEditorStateCandleTypesForSave(state: EditorConditionState): EditorConditionState {
|
||||||
|
let next = state;
|
||||||
|
for (const section of collectStartSections(state)) {
|
||||||
|
next = updateStartCandleTypes(next, section.startId, section.candleTypes);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
/** 저장 직전 — 양쪽 START 분봉 합집합을 각 탭에 반영 (매수만 설정한 경우 매도 DSL도 동기화) */
|
/** 저장 직전 — 양쪽 START 분봉 합집합을 각 탭에 반영 (매수만 설정한 경우 매도 DSL도 동기화) */
|
||||||
export function alignBuySellStartCandleTypesForSave(
|
export function alignBuySellStartCandleTypesForSave(
|
||||||
buy: EditorConditionState,
|
buy: EditorConditionState,
|
||||||
@@ -423,7 +433,11 @@ export function alignBuySellStartCandleTypesForSave(
|
|||||||
...getStartCandleTypes(buy.startMeta[START_NODE_ID]),
|
...getStartCandleTypes(buy.startMeta[START_NODE_ID]),
|
||||||
...getStartCandleTypes(sell.startMeta[START_NODE_ID]),
|
...getStartCandleTypes(sell.startMeta[START_NODE_ID]),
|
||||||
]);
|
]);
|
||||||
return syncBuySellPrimaryStartCandleTypes(buy, sell, merged);
|
const synced = syncBuySellPrimaryStartCandleTypes(buy, sell, merged);
|
||||||
|
return {
|
||||||
|
buy: syncEditorStateCandleTypesForSave(synced.buy),
|
||||||
|
sell: syncEditorStateCandleTypesForSave(synced.sell),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 저장된 DSL에서 Logic Expression용 분기 목록 */
|
/** 저장된 DSL에서 Logic Expression용 분기 목록 */
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
encodeConditionForSave,
|
encodeConditionForSave,
|
||||||
mergeStartMetaForLoad,
|
mergeStartMetaForLoad,
|
||||||
normalizeStartCombineOp,
|
normalizeStartCombineOp,
|
||||||
|
syncEditorStateCandleTypesForSave,
|
||||||
type EditorConditionState,
|
type EditorConditionState,
|
||||||
} from './strategyConditionSerde';
|
} from './strategyConditionSerde';
|
||||||
import { getStartCandleTypes, normalizeStartCandleType } from './strategyStartNodes';
|
import { getStartCandleTypes, normalizeStartCandleType } from './strategyStartNodes';
|
||||||
@@ -59,8 +60,20 @@ export function collectUiEvaluationTimeframes(
|
|||||||
return collectFromStrategyDto(strategy);
|
return collectFromStrategyDto(strategy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function evaluationTimeframesMatch(uiTfs: string[], serverTfs: string[]): boolean {
|
||||||
|
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
||||||
|
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
||||||
|
const missingOnServer = uiTfs.some(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
||||||
|
const extraOnServer = [...serverSet].some(tf => !uiSet.has(tf));
|
||||||
|
if (!missingOnServer && !extraOnServer) return true;
|
||||||
|
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI 분봉과 서버 DSL 불일치 시 안내.
|
* UI 분봉과 서버 DSL 불일치 시 자동 동기화 시도 후, 여전히 다르면 안내.
|
||||||
* @returns false — 진행 불가(저장 필요)
|
* @returns false — 진행 불가(저장 필요)
|
||||||
*/
|
*/
|
||||||
export async function warnStrategyTimeframeMismatch(
|
export async function warnStrategyTimeframeMismatch(
|
||||||
@@ -68,7 +81,8 @@ export async function warnStrategyTimeframeMismatch(
|
|||||||
strategy?: StrategyDto | null,
|
strategy?: StrategyDto | null,
|
||||||
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
|
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strategy, editorState);
|
const strat = strategy ?? await loadStrategy(strategyId);
|
||||||
|
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat, editorState);
|
||||||
if (uiTfs.length === 0) return true;
|
if (uiTfs.length === 0) return true;
|
||||||
|
|
||||||
let serverTfs: string[];
|
let serverTfs: string[];
|
||||||
@@ -77,18 +91,27 @@ export async function warnStrategyTimeframeMismatch(
|
|||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true;
|
||||||
|
|
||||||
|
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(strategyId, strat);
|
||||||
|
if (synced) {
|
||||||
|
try {
|
||||||
|
serverTfs = await loadStrategyTimeframes(strategyId);
|
||||||
|
if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
||||||
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
||||||
const missingOnServer = uiTfs.filter(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
const uiLabel = uiTfs.map(tf => normalizeStartCandleType(tf)).join(', ') || '1m';
|
||||||
const extraOnServer = [...serverSet].filter(tf => !uiSet.has(tf));
|
const serverLabel = [...serverSet].join(', ') || '1m';
|
||||||
|
if (uiSet.size === serverSet.size && [...uiSet].every(tf => serverSet.has(tf))) {
|
||||||
if (missingOnServer.length === 0 && extraOnServer.length === 0) return true;
|
|
||||||
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uiLabel = uiTfs.map(tf => normalizeStartCandleType(tf)).join(', ') || '1m';
|
|
||||||
const serverLabel = [...serverSet].join(', ') || '1m';
|
|
||||||
window.alert(
|
window.alert(
|
||||||
`전략 화면에는 ${uiLabel} 봉으로 설정되어 있지만, 서버에는 ${serverLabel} 만 저장되어 있습니다.\n\n`
|
`전략 화면에는 ${uiLabel} 봉으로 설정되어 있지만, 서버에는 ${serverLabel} 만 저장되어 있습니다.\n\n`
|
||||||
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 DB에 반영한 뒤 실시간 체크를 켜 주세요.',
|
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 DB에 반영한 뒤 실시간 체크를 켜 주세요.',
|
||||||
@@ -105,13 +128,14 @@ function buildEditorStateFromStrategy(
|
|||||||
(side === 'buy' ? strategy.buyCondition : strategy.sellCondition) as LogicNode | null,
|
(side === 'buy' ? strategy.buyCondition : strategy.sellCondition) as LogicNode | null,
|
||||||
);
|
);
|
||||||
const snap = layout?.[side];
|
const snap = layout?.[side];
|
||||||
return {
|
const merged: EditorConditionState = {
|
||||||
root: decoded.root,
|
root: decoded.root,
|
||||||
startMeta: mergeStartMetaForLoad(decoded.startMeta, snap?.startMeta),
|
startMeta: mergeStartMetaForLoad(decoded.startMeta, snap?.startMeta),
|
||||||
extraStartIds: snap?.extraStartIds?.length ? snap.extraStartIds : decoded.extraStartIds,
|
extraStartIds: snap?.extraStartIds?.length ? snap.extraStartIds : decoded.extraStartIds,
|
||||||
extraRoots: snap?.extraRoots ?? decoded.extraRoots,
|
extraRoots: snap?.extraRoots ?? decoded.extraRoots,
|
||||||
startCombineOp: normalizeStartCombineOp(snap?.startCombineOp ?? decoded.startCombineOp),
|
startCombineOp: normalizeStartCombineOp(snap?.startCombineOp ?? decoded.startCombineOp),
|
||||||
};
|
};
|
||||||
|
return syncEditorStateCandleTypesForSave(merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,7 +145,7 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
|||||||
strategyId: number,
|
strategyId: number,
|
||||||
strategy?: StrategyDto | null,
|
strategy?: StrategyDto | null,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const strat = strategy ?? await loadStrategy(strategyId);
|
const strat = (await loadStrategy(strategyId)) ?? strategy ?? null;
|
||||||
if (!strat) return true;
|
if (!strat) return true;
|
||||||
|
|
||||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat);
|
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat);
|
||||||
@@ -133,14 +157,18 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
|||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
|
||||||
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true;
|
||||||
const missingOnServer = uiTfs.filter(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
|
||||||
if (missingOnServer.length === 0) return true;
|
|
||||||
|
|
||||||
const layout = strat.flowLayout;
|
const layout = strat.flowLayout;
|
||||||
if (!layout) {
|
if (!layout) {
|
||||||
return warnStrategyTimeframeMismatch(strategyId, strat);
|
try {
|
||||||
|
await repairStrategyTimeframes(strategyId);
|
||||||
|
const repaired = await loadStrategyTimeframes(strategyId);
|
||||||
|
return evaluationTimeframesMatch(uiTfs, repaired);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buyState = buildEditorStateFromStrategy(strat, layout, 'buy');
|
const buyState = buildEditorStateFromStrategy(strat, layout, 'buy');
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
import { useSyncExternalStore } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
import { TickMarkType, type Time } from 'lightweight-charts';
|
import { TickMarkType, type Time } from 'lightweight-charts';
|
||||||
import type { Timeframe } from '../types';
|
import type { Timeframe } from '../types';
|
||||||
|
import {
|
||||||
|
formatLwcTickMarkWithPattern,
|
||||||
|
formatLwcTimeWithPattern,
|
||||||
|
getChartTimeFormat,
|
||||||
|
} from './chartTimeFormat';
|
||||||
|
|
||||||
export const DEFAULT_DISPLAY_TIMEZONE = 'Asia/Seoul';
|
export const DEFAULT_DISPLAY_TIMEZONE = 'Asia/Seoul';
|
||||||
|
|
||||||
@@ -68,10 +73,6 @@ export function getTimezoneAbbr(tz: string): string {
|
|||||||
return TIMEZONE_OPTIONS.find(o => o.id === id)?.abbr ?? id.split('/').pop() ?? 'TZ';
|
return TIMEZONE_OPTIONS.find(o => o.id === id)?.abbr ?? id.split('/').pop() ?? 'TZ';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDailyTimeframe(tf?: string): boolean {
|
|
||||||
return tf === '1D' || tf === '1W' || tf === '1M';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 하단 바·시계 등 HH:mm:ss */
|
/** 하단 바·시계 등 HH:mm:ss */
|
||||||
export function formatUnixClock(unixSec: number, tz?: string, withSeconds = true): string {
|
export function formatUnixClock(unixSec: number, tz?: string, withSeconds = true): string {
|
||||||
if (!unixSec) return '–';
|
if (!unixSec) return '–';
|
||||||
@@ -90,24 +91,7 @@ export function formatUnixClock(unixSec: number, tz?: string, withSeconds = true
|
|||||||
/** 차트 축·범례용 */
|
/** 차트 축·범례용 */
|
||||||
export function formatUnixForChart(unixSec: number, timeframe?: Timeframe, tz?: string): string {
|
export function formatUnixForChart(unixSec: number, timeframe?: Timeframe, tz?: string): string {
|
||||||
if (!unixSec) return '';
|
if (!unixSec) return '';
|
||||||
const zone = normalizeTimezone(tz ?? _tz);
|
return formatLwcTimeWithPattern(unixSec, timeframe, tz ?? _tz, getChartTimeFormat());
|
||||||
const d = new Date(unixSec * 1000);
|
|
||||||
if (isDailyTimeframe(timeframe)) {
|
|
||||||
return new Intl.DateTimeFormat('en-GB', {
|
|
||||||
timeZone: zone,
|
|
||||||
year: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
}).format(d);
|
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat('en-GB', {
|
|
||||||
timeZone: zone,
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
}).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LWC Time → unix 초 (없으면 null) */
|
/** LWC Time → unix 초 (없으면 null) */
|
||||||
@@ -126,17 +110,7 @@ export function formatLwcTime(
|
|||||||
timeframe?: Timeframe,
|
timeframe?: Timeframe,
|
||||||
tz?: string,
|
tz?: string,
|
||||||
): string {
|
): string {
|
||||||
if (typeof time === 'number') {
|
return formatLwcTimeWithPattern(time, timeframe, tz ?? _tz, getChartTimeFormat());
|
||||||
return formatUnixForChart(time, timeframe, tz);
|
|
||||||
}
|
|
||||||
const zone = normalizeTimezone(tz ?? _tz);
|
|
||||||
const utc = Date.UTC(time.year, time.month - 1, time.day);
|
|
||||||
return new Intl.DateTimeFormat('en-GB', {
|
|
||||||
timeZone: zone,
|
|
||||||
year: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
}).format(new Date(utc));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LWC X축 눈금 — 선택한 표시 시간대 기준 (기본 포맷터는 브라우저 로컬 TZ 사용) */
|
/** LWC X축 눈금 — 선택한 표시 시간대 기준 (기본 포맷터는 브라우저 로컬 TZ 사용) */
|
||||||
@@ -146,43 +120,13 @@ export function formatLwcTickMark(
|
|||||||
timeframe?: Timeframe,
|
timeframe?: Timeframe,
|
||||||
tz?: string,
|
tz?: string,
|
||||||
): string {
|
): string {
|
||||||
const zone = normalizeTimezone(tz ?? _tz);
|
return formatLwcTickMarkWithPattern(
|
||||||
const unixSec = lwcTimeToUnix(time);
|
time,
|
||||||
if (unixSec == null) return '';
|
tickMarkType,
|
||||||
const d = new Date(unixSec * 1000);
|
timeframe,
|
||||||
|
tz ?? _tz,
|
||||||
switch (tickMarkType) {
|
getChartTimeFormat(),
|
||||||
case TickMarkType.Year:
|
);
|
||||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric' }).format(d);
|
|
||||||
case TickMarkType.Month:
|
|
||||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, month: 'short' }).format(d);
|
|
||||||
case TickMarkType.DayOfMonth:
|
|
||||||
if (isDailyTimeframe(timeframe)) {
|
|
||||||
return new Intl.DateTimeFormat('en-GB', {
|
|
||||||
timeZone: zone,
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}).format(d);
|
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, day: 'numeric' }).format(d);
|
|
||||||
case TickMarkType.Time:
|
|
||||||
return new Intl.DateTimeFormat('en-GB', {
|
|
||||||
timeZone: zone,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
}).format(d);
|
|
||||||
case TickMarkType.TimeWithSeconds:
|
|
||||||
return new Intl.DateTimeFormat('en-GB', {
|
|
||||||
timeZone: zone,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
}).format(d);
|
|
||||||
default:
|
|
||||||
return formatUnixForChart(unixSec, timeframe, zone);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 하단 바 표시: `06:36:00 KST` */
|
/** 하단 바 표시: `06:36:00 KST` */
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function buildSignalDetailRows(item: TradeSignalDisplaySource): Array<{ l
|
|||||||
{ label: '종목', value: primary !== secondary ? `${secondary} · ${primary}` : secondary },
|
{ label: '종목', value: primary !== secondary ? `${secondary} · ${primary}` : secondary },
|
||||||
{ label: '매매 구분', value: getSignalTypeKo(item.signalType), highlight: true },
|
{ label: '매매 구분', value: getSignalTypeKo(item.signalType), highlight: true },
|
||||||
{ label: '기준 가격', value: formatSignalPrice(item.price), highlight: true },
|
{ label: '기준 가격', value: formatSignalPrice(item.price), highlight: true },
|
||||||
{ label: '캔들 시각', value: `${formatSignalTime(item.candleTime)} · ${item.candleType ?? '1m'}` },
|
{ label: '캔들 시각', value: `${formatSignalTime(item.candleTime)} · ${formatCandleTypeKo(item.candleType)}` },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (item.receivedAt) {
|
if (item.receivedAt) {
|
||||||
|
|||||||
Reference in New Issue
Block a user