전략편집기 복합지표 기능 반영
This commit is contained in:
@@ -158,6 +158,9 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
String condType = cond.path("conditionType").asText("GT");
|
String condType = cond.path("conditionType").asText("GT");
|
||||||
String leftField = cond.path("leftField").asText("NONE");
|
String leftField = cond.path("leftField").asText("NONE");
|
||||||
String rightField = cond.path("rightField").asText("NONE");
|
String rightField = cond.path("rightField").asText("NONE");
|
||||||
|
int condPeriod = cond.path("period").asInt(-1);
|
||||||
|
int leftPeriod = cond.path("leftPeriod").asInt(-1);
|
||||||
|
int rightPeriod = cond.path("rightPeriod").asInt(-1);
|
||||||
int slopePeriod = cond.path("slopePeriod").asInt(3);
|
int slopePeriod = cond.path("slopePeriod").asInt(3);
|
||||||
int holdDays = cond.path("holdDays").asInt(3);
|
int holdDays = cond.path("holdDays").asInt(3);
|
||||||
|
|
||||||
@@ -168,8 +171,8 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
: Map.of();
|
: Map.of();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Indicator<Num> left = resolveField(leftField, indType, indParams, series);
|
Indicator<Num> left = resolveField(leftField, indType, indParams, series, condPeriod, leftPeriod);
|
||||||
Indicator<Num> right = resolveField(rightField, indType, indParams, series);
|
Indicator<Num> right = resolveField(rightField, indType, indParams, series, condPeriod, rightPeriod);
|
||||||
|
|
||||||
return switch (condType) {
|
return switch (condType) {
|
||||||
case "GT" -> new OverIndicatorRule(left, right);
|
case "GT" -> new OverIndicatorRule(left, right);
|
||||||
@@ -225,7 +228,8 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
// ── 필드 Resolver ─────────────────────────────────────────────────────────
|
// ── 필드 Resolver ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Indicator<Num> resolveField(String field, String indType,
|
private Indicator<Num> resolveField(String field, String indType,
|
||||||
Map<String, Object> p, BarSeries s) {
|
Map<String, Object> p, BarSeries s,
|
||||||
|
int condPeriod, int sidePeriod) {
|
||||||
if (field == null || field.isBlank() || field.equals("NONE")) {
|
if (field == null || field.isBlank() || field.equals("NONE")) {
|
||||||
return new ConstantIndicator<>(s, s.numFactory().numOf(0));
|
return new ConstantIndicator<>(s, s.numFactory().numOf(0));
|
||||||
}
|
}
|
||||||
@@ -240,7 +244,7 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
if (!Double.isNaN(constant)) {
|
if (!Double.isNaN(constant)) {
|
||||||
yield new ConstantIndicator<>(s, s.numFactory().numOf(constant));
|
yield new ConstantIndicator<>(s, s.numFactory().numOf(constant));
|
||||||
}
|
}
|
||||||
yield resolveIndicatorField(field, indType, p, s);
|
yield resolveIndicatorField(field, indType, p, s, condPeriod, sidePeriod);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -266,15 +270,25 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Indicator<Num> resolveIndicatorField(String field, String indType,
|
private Indicator<Num> resolveIndicatorField(String field, String indType,
|
||||||
Map<String, Object> p, BarSeries s) {
|
Map<String, Object> p, BarSeries s,
|
||||||
|
int condPeriod, int sidePeriod) {
|
||||||
ClosePriceIndicator close = new ClosePriceIndicator(s);
|
ClosePriceIndicator close = new ClosePriceIndicator(s);
|
||||||
|
int periodOverride = sidePeriod > 0 ? sidePeriod : (condPeriod > 0 ? condPeriod : -1);
|
||||||
|
|
||||||
// CCI — 프론트엔드 indicatorRegistry 기본값 20 과 통일
|
// CCI — 기간 접미사(CCI_VALUE_13) 또는 기본 CCI_VALUE
|
||||||
|
if (field.startsWith("CCI_VALUE_")) {
|
||||||
|
int period = parseTrailingInt(field, "CCI_VALUE_", intP(p, "length", 13));
|
||||||
|
return new CCIIndicator(s, period);
|
||||||
|
}
|
||||||
if (field.equals("CCI_VALUE") || indType.equals("CCI"))
|
if (field.equals("CCI_VALUE") || indType.equals("CCI"))
|
||||||
return new CCIIndicator(s, intP(p, "length", 13));
|
return new CCIIndicator(s, periodOverride > 0 ? periodOverride : intP(p, "length", 13));
|
||||||
// RSI — 표준 기본값 14
|
// RSI — 기간 접미사(RSI_VALUE_9) 또는 기본 RSI_VALUE
|
||||||
|
if (field.startsWith("RSI_VALUE_")) {
|
||||||
|
int period = parseTrailingInt(field, "RSI_VALUE_", intP(p, "length", 14));
|
||||||
|
return new RSIIndicator(close, period);
|
||||||
|
}
|
||||||
if (field.equals("RSI_VALUE"))
|
if (field.equals("RSI_VALUE"))
|
||||||
return new RSIIndicator(close, intP(p, "length", 14));
|
return new RSIIndicator(close, periodOverride > 0 ? periodOverride : intP(p, "length", 14));
|
||||||
// MACD
|
// MACD
|
||||||
if (field.equals("MACD_LINE")) {
|
if (field.equals("MACD_LINE")) {
|
||||||
return new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26));
|
return new MACDIndicator(close, intP(p, "fastLength", 12), intP(p, "slowLength", 26));
|
||||||
@@ -289,6 +303,10 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
return NumericIndicator.of(macd).minus(new EMAIndicator(macd, intP(p, "signalLength", 9)));
|
return NumericIndicator.of(macd).minus(new EMAIndicator(macd, intP(p, "signalLength", 9)));
|
||||||
}
|
}
|
||||||
// ADX / DMI
|
// ADX / DMI
|
||||||
|
if (field.startsWith("ADX_VALUE_")) {
|
||||||
|
int period = parseTrailingInt(field, "ADX_VALUE_", intP(p, "adxSmoothing", 14));
|
||||||
|
return new ADXIndicator(s, intP(p, "diLength", 14), period);
|
||||||
|
}
|
||||||
if (field.equals("ADX_VALUE"))
|
if (field.equals("ADX_VALUE"))
|
||||||
return new ADXIndicator(s, intP(p, "diLength", 14), intP(p, "adxSmoothing", 14));
|
return new ADXIndicator(s, intP(p, "diLength", 14), intP(p, "adxSmoothing", 14));
|
||||||
if (field.equals("PDI") || field.equals("PLUS_DI")) {
|
if (field.equals("PDI") || field.equals("PLUS_DI")) {
|
||||||
@@ -316,6 +334,12 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
return new SMAIndicator(slowK, dSmooth);
|
return new SMAIndicator(slowK, dSmooth);
|
||||||
}
|
}
|
||||||
// TRIX — ln(종가) → 3×EMA → Δ×10000 (업비트)
|
// TRIX — ln(종가) → 3×EMA → Δ×10000 (업비트)
|
||||||
|
if (field.startsWith("TRIX_VALUE_")) {
|
||||||
|
int len = parseTrailingInt(field, "TRIX_VALUE_", intP(p, "length", 12));
|
||||||
|
EMAIndicator e3 = tripleLogEma(close, len);
|
||||||
|
PreviousValueIndicator prev3 = new PreviousValueIndicator(e3);
|
||||||
|
return NumericIndicator.of(e3).minus(prev3).multipliedBy(10_000);
|
||||||
|
}
|
||||||
if (field.equals("TRIX_VALUE")) {
|
if (field.equals("TRIX_VALUE")) {
|
||||||
int len = intP(p, "length", 12);
|
int len = intP(p, "length", 12);
|
||||||
EMAIndicator e3 = tripleLogEma(close, len);
|
EMAIndicator e3 = tripleLogEma(close, len);
|
||||||
@@ -342,6 +366,10 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
// VR
|
// VR
|
||||||
|
if (field.startsWith("VR_VALUE_")) {
|
||||||
|
int period = parseTrailingInt(field, "VR_VALUE_", intP(p, "length", 10));
|
||||||
|
return vrIndicator(s, period);
|
||||||
|
}
|
||||||
if (field.equals("VR_VALUE") || indType.equals("VR"))
|
if (field.equals("VR_VALUE") || indType.equals("VR"))
|
||||||
return vrIndicator(s, intP(p, "length", 10));
|
return vrIndicator(s, intP(p, "length", 10));
|
||||||
// 이격도 DISPARITY5 → period 5
|
// 이격도 DISPARITY5 → period 5
|
||||||
@@ -349,12 +377,24 @@ public class StrategyDslToTa4jAdapter {
|
|||||||
int period = parseTrailingInt(field, "DISPARITY", 20);
|
int period = parseTrailingInt(field, "DISPARITY", 20);
|
||||||
return disparityIndicator(s, period, p);
|
return disparityIndicator(s, period, p);
|
||||||
}
|
}
|
||||||
|
if (field.startsWith("PSY_VALUE_")) {
|
||||||
|
int period = parseTrailingInt(field, "PSY_VALUE_", intP(p, "length", 12));
|
||||||
|
return newPsychIndicator(s, period, false);
|
||||||
|
}
|
||||||
if (field.equals("PSY_VALUE") || field.equals("NEW_PSY_VALUE")
|
if (field.equals("PSY_VALUE") || field.equals("NEW_PSY_VALUE")
|
||||||
|| indType.equals("PSYCHOLOGICAL") || indType.equals("NEW_PSYCHOLOGICAL"))
|
|| indType.equals("PSYCHOLOGICAL") || indType.equals("NEW_PSYCHOLOGICAL"))
|
||||||
return newPsychIndicator(s, intP(p, "length", 12), false);
|
return newPsychIndicator(s, intP(p, "length", 12), false);
|
||||||
|
if (field.startsWith("INVEST_PSY_VALUE_")) {
|
||||||
|
int period = parseTrailingInt(field, "INVEST_PSY_VALUE_", intP(p, "length", 10));
|
||||||
|
return newPsychIndicator(s, period, true);
|
||||||
|
}
|
||||||
if (field.equals("INVEST_PSY_VALUE") || indType.equals("INVEST_PSYCHOLOGICAL"))
|
if (field.equals("INVEST_PSY_VALUE") || indType.equals("INVEST_PSYCHOLOGICAL"))
|
||||||
return newPsychIndicator(s, intP(p, "length", 10), true);
|
return newPsychIndicator(s, intP(p, "length", 10), true);
|
||||||
// Williams %R
|
// Williams %R
|
||||||
|
if (field.startsWith("WILLIAMS_R_VALUE_")) {
|
||||||
|
int period = parseTrailingInt(field, "WILLIAMS_R_VALUE_", intP(p, "length", 14));
|
||||||
|
return new WilliamsRIndicator(s, period);
|
||||||
|
}
|
||||||
if (field.equals("WILLIAMS_R_VALUE")) return new WilliamsRIndicator(s, intP(p, "length", 14));
|
if (field.equals("WILLIAMS_R_VALUE")) return new WilliamsRIndicator(s, intP(p, "length", 14));
|
||||||
// Donchian Channel — DC_UPPER_20 / DC_LOWER_10 등 기간 접미사
|
// Donchian Channel — DC_UPPER_20 / DC_LOWER_10 등 기간 접미사
|
||||||
if (field.startsWith("DC_UPPER_")) {
|
if (field.startsWith("DC_UPPER_")) {
|
||||||
|
|||||||
+270
-5
@@ -5086,10 +5086,18 @@ html.theme-blue {
|
|||||||
.ob-col-price { width: 36%; }
|
.ob-col-price { width: 36%; }
|
||||||
|
|
||||||
.ob-table--mid {
|
.ob-table--mid {
|
||||||
flex-shrink: 0;
|
display: none;
|
||||||
border-top: 1px solid var(--border);
|
}
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg2);
|
.ob-td-price--live {
|
||||||
|
box-shadow: inset 0 0 0 2px #1565c0;
|
||||||
|
font-weight: 800;
|
||||||
|
background: rgba(21, 101, 192, 0.12);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.app.light .ob-td-price--live {
|
||||||
|
box-shadow: inset 0 0 0 2px #1976d2;
|
||||||
|
background: rgba(25, 118, 210, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ob-tr-mid .ob-td-price--current {
|
.ob-tr-mid .ob-td-price--current {
|
||||||
@@ -5290,6 +5298,59 @@ html.theme-blue {
|
|||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
.ob-summary-time-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ob-summary-time-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.ob-summary-time-btn:hover {
|
||||||
|
color: var(--accent, #2962ff);
|
||||||
|
}
|
||||||
|
.ob-summary-time-caret {
|
||||||
|
font-size: 9px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.ob-summary-mode-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 20;
|
||||||
|
min-width: 72px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg2, #1e222d);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.ob-summary-mode-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ob-summary-mode-item:hover {
|
||||||
|
background: var(--bg3);
|
||||||
|
}
|
||||||
|
.ob-summary-mode-item--active {
|
||||||
|
color: var(--accent, #2962ff);
|
||||||
|
}
|
||||||
.ob-summary-num {
|
.ob-summary-num {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -6213,6 +6274,119 @@ html.theme-blue {
|
|||||||
border-radius: var(--radius) var(--radius) 0 0;
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
background: rgba(0,0,0,.15);
|
background: rgba(0,0,0,.15);
|
||||||
}
|
}
|
||||||
|
.sp-node-head--cond {
|
||||||
|
position: relative;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sp-node-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.sp-node-settings {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(230, 194, 0, 0.12);
|
||||||
|
color: #e6c200;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sp-node-settings:hover {
|
||||||
|
background: rgba(230, 194, 0, 0.22);
|
||||||
|
}
|
||||||
|
.sp-node-settings-pop {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 8px;
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, #e6c200 45%, transparent);
|
||||||
|
background: var(--bg2);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.sp-node-settings-pop .se-flow-settings-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e6c200;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.sp-node-settings-pop .se-flow-settings-close,
|
||||||
|
.se-flow-settings-close {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sp-node-settings-pop .se-flow-settings-close:hover,
|
||||||
|
.se-flow-settings-close:hover {
|
||||||
|
color: #e6c200;
|
||||||
|
}
|
||||||
|
.sp-node-settings-pop .se-flow-settings-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
.sp-node-settings-pop .se-flow-settings-field input,
|
||||||
|
.se-flow-settings-field .se-combo-num-input,
|
||||||
|
.sp-node-settings-pop .se-combo-num-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.se-combo-num {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.se-combo-num-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--se-input-border, var(--border));
|
||||||
|
background: var(--se-input-bg, var(--bg3));
|
||||||
|
color: var(--se-text, var(--text));
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.se-combo-num-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--se-input-focus, var(--accent));
|
||||||
|
}
|
||||||
|
.se-flow-settings-field .se-combo-num-input {
|
||||||
|
border-color: var(--se-input-border);
|
||||||
|
background: var(--se-input-bg);
|
||||||
|
}
|
||||||
|
.sp-node-settings-pop .se-flow-settings-hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
.sp-node-badge {
|
.sp-node-badge {
|
||||||
font-size: 12px; font-weight: 700; padding: 3px 10px;
|
font-size: 12px; font-weight: 700; padding: 3px 10px;
|
||||||
border-radius: 99px; color: #fff; white-space: nowrap;
|
border-radius: 99px; color: #fff; white-space: nowrap;
|
||||||
@@ -6262,6 +6436,24 @@ html.theme-blue {
|
|||||||
color: var(--text); border-radius: var(--radius);
|
color: var(--text); border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.sp-cond-num:focus { outline: none; border-color: var(--accent); }
|
.sp-cond-num:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.sp-cond-editor--composite { padding-top: 6px; }
|
||||||
|
.sp-cond-composite-badge {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c084fc;
|
||||||
|
background: color-mix(in srgb, #c084fc 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, #c084fc 28%, transparent);
|
||||||
|
}
|
||||||
|
.sp-cond-composite-val {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 4px 0 2px;
|
||||||
|
}
|
||||||
|
.sp-cond-num--inline { margin-top: 2px; width: 100%; box-sizing: border-box; }
|
||||||
|
|
||||||
/* ── 우측 팔레트 ──────────────────────────────────────────────────────────── */
|
/* ── 우측 팔레트 ──────────────────────────────────────────────────────────── */
|
||||||
.sp-palette {
|
.sp-palette {
|
||||||
@@ -8809,9 +9001,12 @@ html.theme-blue {
|
|||||||
.rsp-trade-stack .rsp-trade-card-body {
|
.rsp-trade-stack .rsp-trade-card-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
container-type: size;
|
||||||
|
container-name: trade-card;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rsp-trade-card {
|
.rsp-trade-card {
|
||||||
@@ -8893,6 +9088,7 @@ html.theme-blue {
|
|||||||
}
|
}
|
||||||
.rsp-trade-card-body .top-actions {
|
.rsp-trade-card-body .top-actions {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
.rsp-trade-card-body .top-reset,
|
.rsp-trade-card-body .top-reset,
|
||||||
.rsp-trade-card-body .top-submit {
|
.rsp-trade-card-body .top-submit {
|
||||||
@@ -8900,6 +9096,75 @@ html.theme-blue {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 가격·수량: 기본 세로 배치, 공간 부족 시 한 줄 */
|
||||||
|
.top-price-qty-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.top-price-qty-block .top-field {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.top-price-qty-block .top-pct-row--block {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsp-trade-card-body .top-price-qty-block .top-field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.rsp-trade-card-body .top-price-qty-block .top-pct-row--block {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container trade-card (max-height: 360px) {
|
||||||
|
.rsp-trade-card-body .top-price-qty-block {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
column-gap: 8px;
|
||||||
|
row-gap: 4px;
|
||||||
|
}
|
||||||
|
.rsp-trade-card-body .top-price-qty-block .top-field--price {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
.rsp-trade-card-body .top-price-qty-block .top-field--qty {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
.rsp-trade-card-body .top-price-qty-block .top-pct-row--block {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 2;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 780px) {
|
||||||
|
@supports not (container-type: size) {
|
||||||
|
.rsp-trade-card-body .top-price-qty-block {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
column-gap: 8px;
|
||||||
|
row-gap: 4px;
|
||||||
|
}
|
||||||
|
.rsp-trade-card-body .top-price-qty-block .top-field--price {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
.rsp-trade-card-body .top-price-qty-block .top-field--qty {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
.rsp-trade-card-body .top-price-qty-block .top-pct-row--block {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 2;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 호가 탭: 카드 박스 + 실시간 호가 */
|
/* 호가 탭: 카드 박스 + 실시간 호가 */
|
||||||
.rsp-ob-stack {
|
.rsp-ob-stack {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -1499,7 +1499,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{menuPage === 'strategy-editor' && (
|
{menuPage === 'strategy-editor' && (
|
||||||
<StrategyEditorPage theme={theme} activeIndicators={indicators} />
|
<StrategyEditorPage theme={theme} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */}
|
{/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* 실시간 호가 — 증권앱형 통합 레이아웃
|
* 실시간 호가 — 증권앱형 통합 레이아웃
|
||||||
* 상단 시세 · 서브탭 · 매도/현재가/매수 테이블 · 우측 시세 · 좌측 체결 · 하단 합계
|
* 상단 시세 · 서브탭 · 매도/현재가/매수 테이블 · 우측 시세 · 좌측 체결 · 하단 합계
|
||||||
*/
|
*/
|
||||||
import React, { memo, useState, useEffect } from 'react';
|
import React, { memo, useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import { formatNowClock, useDisplayTimezone } from '../utils/timezone';
|
import { formatNowClock, useDisplayTimezone } from '../utils/timezone';
|
||||||
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
|
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
|
||||||
import type { RecentTrade } from '../hooks/useUpbitRecentTrades';
|
import type { RecentTrade } from '../hooks/useUpbitRecentTrades';
|
||||||
@@ -35,16 +35,139 @@ function fmtPct(price: number, prevClose: number): string {
|
|||||||
return `${rate >= 0 ? '+' : ''}${rate.toFixed(2)}%`;
|
return `${rate >= 0 ? '+' : ''}${rate.toFixed(2)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtTotalAmount(a: number): string {
|
||||||
|
if (a == null || !isFinite(a)) return '-';
|
||||||
|
return a.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 하단 합계·호가 잔량 표시 모드 — 기본 총액(KRW) */
|
||||||
|
type SummaryDisplayMode = 'amount' | 'quantity';
|
||||||
|
|
||||||
|
function unitDisplayValue(unit: OrderbookDisplayUnit, mode: SummaryDisplayMode): number {
|
||||||
|
return mode === 'amount' ? unit.price * unit.size : unit.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplayValue(value: number, mode: SummaryDisplayMode): string {
|
||||||
|
return mode === 'amount' ? fmtTotalAmount(value) : fmtSize(value);
|
||||||
|
}
|
||||||
|
|
||||||
function fmtTotalSize(s: number): string {
|
function fmtTotalSize(s: number): string {
|
||||||
if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||||
if (s >= 1) return s.toFixed(2);
|
if (s >= 1) return s.toFixed(2);
|
||||||
return s.toFixed(4);
|
return s.toFixed(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SummaryBar({
|
||||||
|
asks,
|
||||||
|
bids,
|
||||||
|
totalAskSize,
|
||||||
|
totalBidSize,
|
||||||
|
clock,
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
}: {
|
||||||
|
asks: OrderbookDisplayUnit[];
|
||||||
|
bids: OrderbookDisplayUnit[];
|
||||||
|
totalAskSize: number;
|
||||||
|
totalBidSize: number;
|
||||||
|
clock: string;
|
||||||
|
mode: SummaryDisplayMode;
|
||||||
|
onModeChange: (mode: SummaryDisplayMode) => void;
|
||||||
|
}) {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const totalAskAmount = useMemo(
|
||||||
|
() => asks.reduce((sum, u) => sum + u.price * u.size, 0),
|
||||||
|
[asks],
|
||||||
|
);
|
||||||
|
const totalBidAmount = useMemo(
|
||||||
|
() => bids.reduce((sum, u) => sum + u.price * u.size, 0),
|
||||||
|
[bids],
|
||||||
|
);
|
||||||
|
|
||||||
|
const askDisplay = mode === 'amount'
|
||||||
|
? fmtTotalAmount(totalAskAmount)
|
||||||
|
: fmtTotalSize(totalAskSize);
|
||||||
|
const bidDisplay = mode === 'amount'
|
||||||
|
? fmtTotalAmount(totalBidAmount)
|
||||||
|
: fmtTotalSize(totalBidSize);
|
||||||
|
|
||||||
|
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDoc);
|
||||||
|
return () => document.removeEventListener('mousedown', onDoc);
|
||||||
|
}, [menuOpen, closeMenu]);
|
||||||
|
|
||||||
|
const pickMode = (next: SummaryDisplayMode) => {
|
||||||
|
onModeChange(next);
|
||||||
|
closeMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="ob-summary-bar">
|
||||||
|
<div className="ob-summary ob-summary--ask">
|
||||||
|
<span className="ob-summary-num">{askDisplay}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ob-summary ob-summary--time ob-summary-time-wrap" ref={wrapRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ob-summary-time-btn"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
onClick={() => setMenuOpen(o => !o)}
|
||||||
|
>
|
||||||
|
<span>{clock}</span>
|
||||||
|
<span className="ob-summary-time-caret" aria-hidden>▾</span>
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="ob-summary-mode-menu" role="menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={mode === 'quantity'}
|
||||||
|
className={`ob-summary-mode-item${mode === 'quantity' ? ' ob-summary-mode-item--active' : ''}`}
|
||||||
|
onClick={() => pickMode('quantity')}
|
||||||
|
>
|
||||||
|
수량
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={mode === 'amount'}
|
||||||
|
className={`ob-summary-mode-item${mode === 'amount' ? ' ob-summary-mode-item--active' : ''}`}
|
||||||
|
onClick={() => pickMode('amount')}
|
||||||
|
>
|
||||||
|
총액
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ob-summary ob-summary--bid">
|
||||||
|
<span className="ob-summary-num">{bidDisplay}</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function coinCode(market: string): string {
|
function coinCode(market: string): string {
|
||||||
return market.replace(/^KRW-/, '');
|
return market.replace(/^KRW-/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 호가 가격과 현재가(체결가) 일치 여부 */
|
||||||
|
function isLivePrice(levelPrice: number, currentPrice: number): boolean {
|
||||||
|
if (levelPrice <= 0 || currentPrice <= 0) return false;
|
||||||
|
const tol = Math.max(1e-8, Math.abs(currentPrice) * 1e-6);
|
||||||
|
return Math.abs(levelPrice - currentPrice) <= tol;
|
||||||
|
}
|
||||||
|
|
||||||
function pctClass(pct: string): string {
|
function pctClass(pct: string): string {
|
||||||
if (pct.startsWith('+')) return 'ob-td-pct--up';
|
if (pct.startsWith('+')) return 'ob-td-pct--up';
|
||||||
if (pct.startsWith('-')) return 'ob-td-pct--dn';
|
if (pct.startsWith('-')) return 'ob-td-pct--dn';
|
||||||
@@ -72,22 +195,28 @@ const COLGROUP = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const AskTableRow = memo(function AskTableRow({
|
const AskTableRow = memo(function AskTableRow({
|
||||||
unit, prevClose, onClick,
|
unit, prevClose, displayMode, maxDisplay, currentPrice, onClick,
|
||||||
}: {
|
}: {
|
||||||
unit: OrderbookDisplayUnit;
|
unit: OrderbookDisplayUnit;
|
||||||
prevClose: number;
|
prevClose: number;
|
||||||
|
displayMode: SummaryDisplayMode;
|
||||||
|
maxDisplay: number;
|
||||||
|
currentPrice: number;
|
||||||
onClick?: (price: number, type: 'ask' | 'bid') => void;
|
onClick?: (price: number, type: 'ask' | 'bid') => void;
|
||||||
}) {
|
}) {
|
||||||
const pct = fmtPct(unit.price, prevClose);
|
const pct = fmtPct(unit.price, prevClose);
|
||||||
|
const displayVal = unitDisplayValue(unit, displayMode);
|
||||||
|
const barPct = maxDisplay > 0 ? (displayVal / maxDisplay) * 100 : 0;
|
||||||
|
const isLive = isLivePrice(unit.price, currentPrice);
|
||||||
return (
|
return (
|
||||||
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'ask') : undefined}>
|
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'ask') : undefined}>
|
||||||
<td className="ob-td ob-td-qty ob-td-qty--ask">
|
<td className="ob-td ob-td-qty ob-td-qty--ask">
|
||||||
<div className="ob-td-bar-wrap">
|
<div className="ob-td-bar-wrap">
|
||||||
<div className="ob-td-bar ob-td-bar--ask" style={{ width: `${Math.min(unit.percentage, 100)}%` }} />
|
<div className="ob-td-bar ob-td-bar--ask" style={{ width: `${Math.min(barPct, 100)}%` }} />
|
||||||
<span className="ob-td-bar-text">{fmtSize(unit.size)}</span>
|
<span className="ob-td-bar-text">{formatDisplayValue(displayVal, displayMode)}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="ob-td ob-td-price">{fmtPrice(unit.price)}</td>
|
<td className={`ob-td ob-td-price${isLive ? ' ob-td-price--live' : ''}`}>{fmtPrice(unit.price)}</td>
|
||||||
<td className={`ob-td ob-td-pct ${pctClass(pct)}`}>{pct}</td>
|
<td className={`ob-td ob-td-pct ${pctClass(pct)}`}>{pct}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -95,21 +224,27 @@ const AskTableRow = memo(function AskTableRow({
|
|||||||
|
|
||||||
/** 가격은 항상 가운데 열 — 매수: 전일대비(좌) | 가격 | 잔량(우) */
|
/** 가격은 항상 가운데 열 — 매수: 전일대비(좌) | 가격 | 잔량(우) */
|
||||||
const BidTableRow = memo(function BidTableRow({
|
const BidTableRow = memo(function BidTableRow({
|
||||||
unit, prevClose, onClick,
|
unit, prevClose, displayMode, maxDisplay, currentPrice, onClick,
|
||||||
}: {
|
}: {
|
||||||
unit: OrderbookDisplayUnit;
|
unit: OrderbookDisplayUnit;
|
||||||
prevClose: number;
|
prevClose: number;
|
||||||
|
displayMode: SummaryDisplayMode;
|
||||||
|
maxDisplay: number;
|
||||||
|
currentPrice: number;
|
||||||
onClick?: (price: number, type: 'ask' | 'bid') => void;
|
onClick?: (price: number, type: 'ask' | 'bid') => void;
|
||||||
}) {
|
}) {
|
||||||
const pct = fmtPct(unit.price, prevClose);
|
const pct = fmtPct(unit.price, prevClose);
|
||||||
|
const displayVal = unitDisplayValue(unit, displayMode);
|
||||||
|
const barPct = maxDisplay > 0 ? (displayVal / maxDisplay) * 100 : 0;
|
||||||
|
const isLive = isLivePrice(unit.price, currentPrice);
|
||||||
return (
|
return (
|
||||||
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'bid') : undefined}>
|
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'bid') : undefined}>
|
||||||
<td className={`ob-td ob-td-pct ob-td-pct--left ${pctClass(pct)}`}>{pct}</td>
|
<td className={`ob-td ob-td-pct ob-td-pct--left ${pctClass(pct)}`}>{pct}</td>
|
||||||
<td className="ob-td ob-td-price">{fmtPrice(unit.price)}</td>
|
<td className={`ob-td ob-td-price${isLive ? ' ob-td-price--live' : ''}`}>{fmtPrice(unit.price)}</td>
|
||||||
<td className="ob-td ob-td-qty ob-td-qty--bid">
|
<td className="ob-td ob-td-qty ob-td-qty--bid">
|
||||||
<div className="ob-td-bar-wrap ob-td-bar-wrap--right">
|
<div className="ob-td-bar-wrap ob-td-bar-wrap--right">
|
||||||
<div className="ob-td-bar ob-td-bar--bid" style={{ width: `${Math.min(unit.percentage, 100)}%` }} />
|
<div className="ob-td-bar ob-td-bar--bid" style={{ width: `${Math.min(barPct, 100)}%` }} />
|
||||||
<span className="ob-td-bar-text">{fmtSize(unit.size)}</span>
|
<span className="ob-td-bar-text">{formatDisplayValue(displayVal, displayMode)}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -257,10 +392,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
|||||||
prevClose, tickerInfo, recentTrades = [], tradeStrength = null, onRowClick,
|
prevClose, tickerInfo, recentTrades = [], tradeStrength = null, onRowClick,
|
||||||
}: OrderbookPanelProps) {
|
}: OrderbookPanelProps) {
|
||||||
const isEmpty = asks.length === 0 && bids.length === 0;
|
const isEmpty = asks.length === 0 && bids.length === 0;
|
||||||
const midPrice = tickerInfo?.tradePrice ?? 0;
|
const currentPrice = tickerInfo?.tradePrice ?? 0;
|
||||||
const midPct = midPrice > 0 ? fmtPct(midPrice, prevClose) : '';
|
|
||||||
const clock = useClock();
|
const clock = useClock();
|
||||||
const isUp = (tickerInfo?.changeRate ?? 0) >= 0;
|
const [summaryMode, setSummaryMode] = useState<SummaryDisplayMode>('amount');
|
||||||
|
|
||||||
|
const maxDisplay = useMemo(() => {
|
||||||
|
const all = [...asks, ...bids];
|
||||||
|
if (all.length === 0) return 0;
|
||||||
|
return Math.max(...all.map(u => unitDisplayValue(u, summaryMode)), 0);
|
||||||
|
}, [asks, bids, summaryMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ob-panel ob-panel--classic">
|
<div className="ob-panel ob-panel--classic">
|
||||||
@@ -301,7 +441,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
|||||||
{COLGROUP}
|
{COLGROUP}
|
||||||
<tbody>
|
<tbody>
|
||||||
{asks.map((unit, i) => (
|
{asks.map((unit, i) => (
|
||||||
<AskTableRow key={`ask-${unit.price}-${i}`} unit={unit} prevClose={prevClose} onClick={onRowClick} />
|
<AskTableRow
|
||||||
|
key={`ask-${unit.price}-${i}`}
|
||||||
|
unit={unit}
|
||||||
|
prevClose={prevClose}
|
||||||
|
displayMode={summaryMode}
|
||||||
|
maxDisplay={maxDisplay}
|
||||||
|
currentPrice={currentPrice}
|
||||||
|
onClick={onRowClick}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -310,21 +458,6 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
|||||||
{tickerInfo && <StatsRail tickerInfo={tickerInfo} prevClose={prevClose} />}
|
{tickerInfo && <StatsRail tickerInfo={tickerInfo} prevClose={prevClose} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 현재가 (가운데 열 정렬) */}
|
|
||||||
<table className="ob-table ob-table--hoga ob-table--mid">
|
|
||||||
{COLGROUP}
|
|
||||||
<tbody>
|
|
||||||
<tr className="ob-tr-mid">
|
|
||||||
<td className="ob-td ob-td--pad" />
|
|
||||||
<td className={`ob-td ob-td-price ob-td-price--current ${isUp ? 'ob-td-price--up' : 'ob-td-price--dn'}`}>
|
|
||||||
<span className="ob-mid-price">{midPrice > 0 ? fmtPrice(midPrice) : '-'}</span>
|
|
||||||
{midPct && <span className={`ob-mid-pct ${pctClass(midPct)}`}>{midPct}</span>}
|
|
||||||
</td>
|
|
||||||
<td className="ob-td ob-td--pad" />
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{/* 매수 구간 + 좌측 체결 */}
|
{/* 매수 구간 + 좌측 체결 */}
|
||||||
<div className="ob-block ob-block--bid">
|
<div className="ob-block ob-block--bid">
|
||||||
<TradesPanel trades={recentTrades} strength={tradeStrength} />
|
<TradesPanel trades={recentTrades} strength={tradeStrength} />
|
||||||
@@ -334,7 +467,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
|||||||
{COLGROUP}
|
{COLGROUP}
|
||||||
<tbody>
|
<tbody>
|
||||||
{bids.map((unit, i) => (
|
{bids.map((unit, i) => (
|
||||||
<BidTableRow key={`bid-${unit.price}-${i}`} unit={unit} prevClose={prevClose} onClick={onRowClick} />
|
<BidTableRow
|
||||||
|
key={`bid-${unit.price}-${i}`}
|
||||||
|
unit={unit}
|
||||||
|
prevClose={prevClose}
|
||||||
|
displayMode={summaryMode}
|
||||||
|
maxDisplay={maxDisplay}
|
||||||
|
currentPrice={currentPrice}
|
||||||
|
onClick={onRowClick}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -345,15 +486,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEmpty && (
|
{!isEmpty && (
|
||||||
<footer className="ob-summary-bar">
|
<SummaryBar
|
||||||
<div className="ob-summary ob-summary--ask">
|
asks={asks}
|
||||||
<span className="ob-summary-num">{fmtTotalSize(totalAskSize)}</span>
|
bids={bids}
|
||||||
</div>
|
totalAskSize={totalAskSize}
|
||||||
<div className="ob-summary ob-summary--time">{clock}</div>
|
totalBidSize={totalBidSize}
|
||||||
<div className="ob-summary ob-summary--bid">
|
clock={clock}
|
||||||
<span className="ob-summary-num">{fmtTotalSize(totalBidSize)}</span>
|
mode={summaryMode}
|
||||||
</div>
|
onModeChange={setSummaryMode}
|
||||||
</footer>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { ReactFlowProvider } from '@xyflow/react';
|
import { ReactFlowProvider } from '@xyflow/react';
|
||||||
import DraggableModalFrame from './DraggableModalFrame';
|
import DraggableModalFrame from './DraggableModalFrame';
|
||||||
import type { Theme, IndicatorConfig } from '../types/index';
|
import type { Theme } from '../types/index';
|
||||||
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||||||
import type { LogicNode } from '../utils/strategyTypes';
|
import type { LogicNode } from '../utils/strategyTypes';
|
||||||
import {
|
import {
|
||||||
buildDef,
|
buildStrategyEditorDef,
|
||||||
loadStratsLocal,
|
loadStratsLocal,
|
||||||
saveStratsLocal,
|
saveStratsLocal,
|
||||||
mergeAtRoot,
|
mergeAtRoot,
|
||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
type StrategyDto,
|
type StrategyDto,
|
||||||
} from '../utils/strategyEditorShared';
|
} from '../utils/strategyEditorShared';
|
||||||
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
||||||
|
import {
|
||||||
|
COMPOSITE_INDICATOR_ITEMS,
|
||||||
|
compositePeriodLabel,
|
||||||
|
} from '../utils/compositeIndicators';
|
||||||
import {
|
import {
|
||||||
emptySignalFlowLayout,
|
emptySignalFlowLayout,
|
||||||
loadStrategyFlowLayout,
|
loadStrategyFlowLayout,
|
||||||
@@ -66,7 +70,6 @@ const TERMINAL_DEFAULT = 140;
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
activeIndicators?: IndicatorConfig[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot {
|
function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot {
|
||||||
@@ -80,8 +83,8 @@ function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StrategyEditorPage({ theme, activeIndicators = [] }: Props) {
|
export default function StrategyEditorPage({ theme }: Props) {
|
||||||
const DEF = useMemo(() => buildDef(activeIndicators), [activeIndicators]);
|
const DEF = useMemo(() => buildStrategyEditorDef(), []);
|
||||||
|
|
||||||
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
|
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
@@ -511,8 +514,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const applyPalette = useCallback((type: string, value: string, label: string) => {
|
const applyPalette = useCallback((type: string, value: string, _label: string, composite = false) => {
|
||||||
const newNode = makeNode(type, value, signalTab, DEF);
|
const newNode = makeNode(type, value, signalTab, DEF, composite ? { composite: true } : undefined);
|
||||||
const root = currentRoot;
|
const root = currentRoot;
|
||||||
if (!root) setCurrentRoot(newNode);
|
if (!root) setCurrentRoot(newNode);
|
||||||
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
|
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
|
||||||
@@ -576,11 +579,11 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
const match = (label: string, desc?: string) =>
|
const match = (label: string, desc?: string) =>
|
||||||
!q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false);
|
!q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false);
|
||||||
|
|
||||||
const paletteKey = (type: 'operator' | 'indicator', value: string) => `${type}:${value}`;
|
const paletteKey = (type: 'operator' | 'indicator' | 'composite', value: string) => `${type}:${value}`;
|
||||||
const selectPalette = (type: 'operator' | 'indicator', value: string) => {
|
const selectPalette = (type: 'operator' | 'indicator' | 'composite', value: string) => {
|
||||||
setSelectedPaletteKey(paletteKey(type, value));
|
setSelectedPaletteKey(paletteKey(type, value));
|
||||||
};
|
};
|
||||||
const isPaletteSelected = (type: 'operator' | 'indicator', value: string) =>
|
const isPaletteSelected = (type: 'operator' | 'indicator' | 'composite', value: string) =>
|
||||||
selectedPaletteKey === paletteKey(type, value);
|
selectedPaletteKey === paletteKey(type, value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -780,7 +783,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="se-sync-tip">차트 설정 동기화 · 기간 {getIndicatorPeriodLabel(selectedLogicNode.condition.indicatorType, DEF) || '—'}</span>
|
<span className="se-sync-tip">전략 조건 전용 설정 · 차트 보조지표와 무관</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -846,7 +849,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
</div>
|
</div>
|
||||||
<div className="se-palette-section se-palette-section--band">
|
<div className="se-palette-section se-palette-section--band">
|
||||||
<h3>밴드 · 추세</h3>
|
<h3>밴드 · 추세</h3>
|
||||||
<div className="se-palette-grid">
|
<div className="se-palette-grid se-palette-grid--3">
|
||||||
{maBandItems.filter(i => match(i.label, i.desc)).map(item => (
|
{maBandItems.filter(i => match(i.label, i.desc)).map(item => (
|
||||||
<PaletteChip
|
<PaletteChip
|
||||||
key={item.value}
|
key={item.value}
|
||||||
@@ -859,9 +862,9 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="se-palette-section se-palette-section--aux se-palette-section--scroll">
|
<div className="se-palette-section se-palette-section--aux">
|
||||||
<h3>보조지표</h3>
|
<h3>보조지표</h3>
|
||||||
<div className="se-palette-grid">
|
<div className="se-palette-grid se-palette-grid--3">
|
||||||
{indicatorItems.filter(i => match(i.label, i.desc)).map(item => (
|
{indicatorItems.filter(i => match(i.label, i.desc)).map(item => (
|
||||||
<PaletteChip
|
<PaletteChip
|
||||||
key={item.value}
|
key={item.value}
|
||||||
@@ -878,6 +881,26 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="se-palette-section se-palette-section--composite se-palette-section--scroll">
|
||||||
|
<h3>복합지표</h3>
|
||||||
|
<div className="se-palette-grid se-palette-grid--3">
|
||||||
|
{COMPOSITE_INDICATOR_ITEMS.filter(i => match(i.label, i.desc)).map(item => (
|
||||||
|
<PaletteChip
|
||||||
|
key={`composite-${item.value}`}
|
||||||
|
type="indicator"
|
||||||
|
value={item.value}
|
||||||
|
label={item.label}
|
||||||
|
desc={item.desc}
|
||||||
|
color="composite"
|
||||||
|
composite
|
||||||
|
period={compositePeriodLabel(item.value, DEF)}
|
||||||
|
selected={isPaletteSelected('composite', item.value)}
|
||||||
|
onSelect={() => selectPalette('composite', item.value)}
|
||||||
|
onAdd={() => applyPalette('indicator', item.value, item.label, true)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{rightTab === 'templates' && (
|
{rightTab === 'templates' && (
|
||||||
|
|||||||
@@ -323,7 +323,8 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="top-field">
|
<div className="top-price-qty-block">
|
||||||
|
<div className="top-field top-field--price">
|
||||||
<label className="top-label">{priceLabel} (KRW)</label>
|
<label className="top-label">{priceLabel} (KRW)</label>
|
||||||
<div className="top-input-group">
|
<div className="top-input-group">
|
||||||
<input
|
<input
|
||||||
@@ -341,7 +342,7 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="top-field">
|
<div className="top-field top-field--qty">
|
||||||
<label className="top-label">주문수량 ({code})</label>
|
<label className="top-label">주문수량 ({code})</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -354,7 +355,9 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
<div className="top-pct-row">
|
</div>
|
||||||
|
|
||||||
|
<div className="top-pct-row top-pct-row--block">
|
||||||
{PCT_BTNS.map(p => (
|
{PCT_BTNS.map(p => (
|
||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const prevChartType = useRef<ChartType>(chartType);
|
const prevChartType = useRef<ChartType>(chartType);
|
||||||
const prevTheme = useRef<Theme>(theme);
|
const prevTheme = useRef<Theme>(theme);
|
||||||
const prevLogScale = useRef<boolean>(logScale);
|
const prevLogScale = useRef<boolean>(logScale);
|
||||||
|
const indicatorsRef = useRef(indicators);
|
||||||
|
const recoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||||
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
||||||
@@ -163,6 +165,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
barsRef.current = bars;
|
barsRef.current = bars;
|
||||||
}, [bars]);
|
}, [bars]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
indicatorsRef.current = indicators;
|
||||||
|
}, [indicators]);
|
||||||
|
|
||||||
const toggleCandleOnly = useCallback(() => {
|
const toggleCandleOnly = useCallback(() => {
|
||||||
setCandleOnlyMode(v => !v);
|
setCandleOnlyMode(v => !v);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -259,6 +265,49 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
const reloadGenRef = useRef(0);
|
const reloadGenRef = useRef(0);
|
||||||
const reloadRetryRef = useRef(0);
|
const reloadRetryRef = useRef(0);
|
||||||
|
const reloadAllRef = useRef<(
|
||||||
|
mgr: ChartManager,
|
||||||
|
newBars: OHLCVBar[],
|
||||||
|
ct: ChartType,
|
||||||
|
th: Theme,
|
||||||
|
ls: boolean,
|
||||||
|
inds: IndicatorConfig[],
|
||||||
|
) => Promise<void>>(async () => {});
|
||||||
|
|
||||||
|
const queueIndicatorRecovery = useCallback(() => {
|
||||||
|
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
||||||
|
recoveryTimerRef.current = window.setTimeout(() => {
|
||||||
|
recoveryTimerRef.current = null;
|
||||||
|
const m = managerRef.current;
|
||||||
|
const barData = barsRef.current;
|
||||||
|
const inds = indicatorsRef.current;
|
||||||
|
if (!m || barData.length === 0) return;
|
||||||
|
if (barsMarket !== undefined && barsMarket !== market) return;
|
||||||
|
|
||||||
|
const expected = countExpectedVisibleIndicators(inds);
|
||||||
|
if (expected === 0) return;
|
||||||
|
if (m.getLoadedIndicatorCount() >= expected) return;
|
||||||
|
|
||||||
|
prevBarsKey.current = '';
|
||||||
|
const ct = prevChartType.current;
|
||||||
|
const th = prevTheme.current;
|
||||||
|
const ls = prevLogScale.current;
|
||||||
|
|
||||||
|
if (m.hasMainSeries()) {
|
||||||
|
void m.reloadIndicatorsOnly(inds).then(() => {
|
||||||
|
if (m.getLoadedIndicatorCount() >= expected) {
|
||||||
|
prevBarsKey.current = barsKey(barData, market);
|
||||||
|
prevIndKey.current = indKey(inds);
|
||||||
|
applyPaneLayout(m);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reloadAllRef.current?.(m, barData, ct, th, ls, inds);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reloadAllRef.current?.(m, barData, ct, th, ls, inds);
|
||||||
|
}, 120);
|
||||||
|
}, [market, barsMarket, applyPaneLayout]);
|
||||||
|
|
||||||
const reloadAll = useCallback(async (
|
const reloadAll = useCallback(async (
|
||||||
mgr: ChartManager,
|
mgr: ChartManager,
|
||||||
@@ -280,6 +329,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
mgr.setData(newBars, ct, th);
|
mgr.setData(newBars, ct, th);
|
||||||
if (gen !== reloadGenRef.current) {
|
if (gen !== reloadGenRef.current) {
|
||||||
prevBarsKey.current = '';
|
prevBarsKey.current = '';
|
||||||
|
queueIndicatorRecovery();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mgr.setTheme(th);
|
mgr.setTheme(th);
|
||||||
@@ -289,6 +339,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
for (const ind of inds) {
|
for (const ind of inds) {
|
||||||
if (gen !== reloadGenRef.current) {
|
if (gen !== reloadGenRef.current) {
|
||||||
prevBarsKey.current = '';
|
prevBarsKey.current = '';
|
||||||
|
queueIndicatorRecovery();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await mgr.addIndicator(ind);
|
await mgr.addIndicator(ind);
|
||||||
@@ -296,22 +347,31 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
|
|
||||||
if (gen !== reloadGenRef.current) {
|
if (gen !== reloadGenRef.current) {
|
||||||
prevBarsKey.current = '';
|
prevBarsKey.current = '';
|
||||||
|
queueIndicatorRecovery();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectIndicators = inds.some(i => i.hidden !== true);
|
const expected = countExpectedVisibleIndicators(inds);
|
||||||
if (expectIndicators && mgr.getIndicatorCount() === 0) {
|
const loaded = mgr.getLoadedIndicatorCount();
|
||||||
|
|
||||||
|
if (expected > 0 && loaded < expected) {
|
||||||
prevBarsKey.current = '';
|
prevBarsKey.current = '';
|
||||||
if (reloadRetryRef.current < 3) {
|
if (reloadRetryRef.current < 4 && mgr.hasMainSeries()) {
|
||||||
reloadRetryRef.current += 1;
|
reloadRetryRef.current += 1;
|
||||||
requestAnimationFrame(() => {
|
await mgr.reloadIndicatorsOnly(inds);
|
||||||
if (managerRef.current) {
|
if (gen !== reloadGenRef.current) {
|
||||||
void reloadAll(managerRef.current, newBars, ct, th, ls, inds);
|
queueIndicatorRecovery();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
if (mgr.getLoadedIndicatorCount() < expected) {
|
||||||
|
if (reloadRetryRef.current < 6) {
|
||||||
|
reloadRetryRef.current += 1;
|
||||||
|
queueIndicatorRecovery();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reloadRetryRef.current = 0;
|
reloadRetryRef.current = 0;
|
||||||
prevChartType.current = ct;
|
prevChartType.current = ct;
|
||||||
@@ -332,6 +392,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
|
|
||||||
if (gen !== reloadGenRef.current) {
|
if (gen !== reloadGenRef.current) {
|
||||||
prevBarsKey.current = '';
|
prevBarsKey.current = '';
|
||||||
|
queueIndicatorRecovery();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +402,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const m = managerRef.current;
|
const m = managerRef.current;
|
||||||
if (!m || !m.hasMainSeries()) return;
|
if (!m || !m.hasMainSeries()) return;
|
||||||
|
const expectedLater = countExpectedVisibleIndicators(indicatorsRef.current);
|
||||||
|
if (expectedLater > 0 && m.getLoadedIndicatorCount() < expectedLater) {
|
||||||
|
prevBarsKey.current = '';
|
||||||
|
queueIndicatorRecovery();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const w = wrapperRef.current;
|
const w = wrapperRef.current;
|
||||||
if (!w || w.clientHeight <= 0) return;
|
if (!w || w.clientHeight <= 0) return;
|
||||||
m.resetPaneHeights(w.clientHeight);
|
m.resetPaneHeights(w.clientHeight);
|
||||||
@@ -349,7 +416,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
if (delay === 1400) onDataLoaded?.();
|
if (delay === 1400) onDataLoaded?.();
|
||||||
}, delay)
|
}, delay)
|
||||||
);
|
);
|
||||||
}, [applyPaneLayout, onDataLoaded, displayTimezone, timeframe, market]);
|
}, [applyPaneLayout, onDataLoaded, displayTimezone, timeframe, market, queueIndicatorRecovery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadAllRef.current = reloadAll;
|
||||||
|
}, [reloadAll]);
|
||||||
|
|
||||||
// ── 차트 초기화 (마운트 시 1회) ──────────────────────────────────────────
|
// ── 차트 초기화 (마운트 시 1회) ──────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -633,6 +704,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
}
|
}
|
||||||
reloadSafetyTimers.current.forEach(clearTimeout);
|
reloadSafetyTimers.current.forEach(clearTimeout);
|
||||||
reloadSafetyTimers.current = [];
|
reloadSafetyTimers.current = [];
|
||||||
|
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
managerRef.current?.destroy();
|
managerRef.current?.destroy();
|
||||||
managerRef.current = null;
|
managerRef.current = null;
|
||||||
@@ -682,10 +754,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const ctChanged = chartType !== prevChartType.current;
|
const ctChanged = chartType !== prevChartType.current;
|
||||||
const thChanged = theme !== prevTheme.current;
|
const thChanged = theme !== prevTheme.current;
|
||||||
const lsChanged = logScale !== prevLogScale.current;
|
const lsChanged = logScale !== prevLogScale.current;
|
||||||
const expectIndicators = indicators.some(i => i.hidden !== true);
|
const expectIndicators = countExpectedVisibleIndicators(indicators);
|
||||||
const indicatorsIncomplete = expectIndicators
|
const loadedIndicators = mgr.getLoadedIndicatorCount();
|
||||||
|
const indicatorsIncomplete = expectIndicators > 0
|
||||||
&& mgr.hasMainSeries()
|
&& mgr.hasMainSeries()
|
||||||
&& mgr.getIndicatorCount() === 0;
|
&& loadedIndicators < expectIndicators;
|
||||||
|
|
||||||
if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged && !indicatorsIncomplete) return;
|
if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged && !indicatorsIncomplete) return;
|
||||||
|
|
||||||
@@ -778,18 +851,24 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
if (!mgr || !chartMgr || bars.length === 0) return;
|
if (!mgr || !chartMgr || bars.length === 0) return;
|
||||||
if (barsMarket !== undefined && barsMarket !== market) return;
|
if (barsMarket !== undefined && barsMarket !== market) return;
|
||||||
|
|
||||||
const expectIndicators = indicators.some(i => i.hidden !== true);
|
const expected = countExpectedVisibleIndicators(indicators);
|
||||||
if (!expectIndicators) return;
|
if (expected === 0) return;
|
||||||
|
if (mgr.getLoadedIndicatorCount() >= expected) return;
|
||||||
|
|
||||||
const t = window.setTimeout(() => {
|
const timers = [120, 400, 900].map(delay =>
|
||||||
|
window.setTimeout(() => {
|
||||||
const m = managerRef.current;
|
const m = managerRef.current;
|
||||||
if (!m || !m.hasMainSeries() || m.getIndicatorCount() > 0) return;
|
if (!m || !m.hasMainSeries()) return;
|
||||||
|
if (barsMarket !== undefined && barsMarket !== market) return;
|
||||||
|
const exp = countExpectedVisibleIndicators(indicatorsRef.current);
|
||||||
|
if (exp === 0 || m.getLoadedIndicatorCount() >= exp) return;
|
||||||
prevBarsKey.current = '';
|
prevBarsKey.current = '';
|
||||||
void reloadAll(m, bars, chartType, theme, logScale, indicators);
|
queueIndicatorRecovery();
|
||||||
}, 120);
|
}, delay),
|
||||||
|
);
|
||||||
|
|
||||||
return () => window.clearTimeout(t);
|
return () => { timers.forEach(clearTimeout); };
|
||||||
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, reloadAll]);
|
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, queueIndicatorRecovery]);
|
||||||
|
|
||||||
// cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
|
// cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
|
||||||
const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {
|
const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {
|
||||||
@@ -988,10 +1067,15 @@ const PaneLegendPortal: React.FC<
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
|
// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
|
||||||
|
function countExpectedVisibleIndicators(inds: IndicatorConfig[]): number {
|
||||||
|
return inds.filter(i => i.hidden !== true).length;
|
||||||
|
}
|
||||||
|
|
||||||
function barsKey(bars: OHLCVBar[], market = ''): string {
|
function barsKey(bars: OHLCVBar[], market = ''): string {
|
||||||
if (bars.length === 0) return '';
|
if (bars.length === 0) return '';
|
||||||
const last = bars[bars.length - 1];
|
const last = bars[bars.length - 1];
|
||||||
return `${market}:${bars.length}:${bars[0].time}:${last.time}:${last.close}`;
|
// last.close 제외 — 틱마다 full reload 방지 (실시간은 updateBar/appendBar 경로)
|
||||||
|
return `${market}:${bars.length}:${bars[0].time}:${last.time}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { useEffect, useId, useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: number;
|
||||||
|
options: number[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
allowDecimal?: boolean;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDraft(raw: string, allowDecimal: boolean): string {
|
||||||
|
if (allowDecimal) {
|
||||||
|
let s = raw.replace(/[^\d.-]/g, '');
|
||||||
|
const minus = s.startsWith('-') ? '-' : '';
|
||||||
|
s = s.replace(/-/g, '');
|
||||||
|
const dot = s.indexOf('.');
|
||||||
|
if (dot !== -1) {
|
||||||
|
s = s.slice(0, dot + 1) + s.slice(dot + 1).replace(/\./g, '');
|
||||||
|
}
|
||||||
|
return minus + s;
|
||||||
|
}
|
||||||
|
return raw.replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDraft(raw: string, allowDecimal: boolean): number | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed || trimmed === '-' || trimmed === '.') return null;
|
||||||
|
const n = allowDecimal ? parseFloat(trimmed) : parseInt(trimmed, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComboNumberInput({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
min = 1,
|
||||||
|
max = 500,
|
||||||
|
allowDecimal = false,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const listId = useId().replace(/:/g, '');
|
||||||
|
const [draft, setDraft] = useState(String(value));
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focused) setDraft(String(value));
|
||||||
|
}, [value, focused]);
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
const parsed = parseDraft(draft, allowDecimal);
|
||||||
|
if (parsed == null) {
|
||||||
|
setDraft(String(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clamped = allowDecimal
|
||||||
|
? Math.max(min, Math.min(max, parsed))
|
||||||
|
: Math.max(min, Math.min(max, Math.round(parsed)));
|
||||||
|
onChange(clamped);
|
||||||
|
setDraft(String(clamped));
|
||||||
|
};
|
||||||
|
|
||||||
|
const uniqueOptions = [...new Set([...options, value])].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="se-combo-num">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="se-combo-num-input"
|
||||||
|
inputMode={allowDecimal ? 'decimal' : 'numeric'}
|
||||||
|
list={listId}
|
||||||
|
value={draft}
|
||||||
|
onChange={e => setDraft(sanitizeDraft(e.target.value, allowDecimal))}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
commit();
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<datalist id={listId}>
|
||||||
|
{uniqueOptions.map(opt => (
|
||||||
|
<option key={opt} value={String(opt)} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import type { ConditionDSL } from '../../utils/strategyTypes';
|
||||||
|
import type { DefType } from '../../utils/strategyEditorShared';
|
||||||
|
import {
|
||||||
|
getConditionRightPeriod,
|
||||||
|
getConditionThreshold,
|
||||||
|
getConditionValuePeriod,
|
||||||
|
getCompositePeriodPresetOptions,
|
||||||
|
getPeriodPresetOptions,
|
||||||
|
getThresholdBounds,
|
||||||
|
getThresholdPresetOptions,
|
||||||
|
hasEditableThreshold,
|
||||||
|
setConditionRightPeriod,
|
||||||
|
setConditionThreshold,
|
||||||
|
setConditionValuePeriod,
|
||||||
|
usesValuePeriodField,
|
||||||
|
} from '../../utils/conditionPeriods';
|
||||||
|
import { compositeDisplayName, compositeElementLabel } from '../../utils/compositeIndicators';
|
||||||
|
import ComboNumberInput from './ComboNumberInput';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
condition: ConditionDSL;
|
||||||
|
def: DefType;
|
||||||
|
onChange: (c: ConditionDSL) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
popoverClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConditionNodeSettings({
|
||||||
|
condition, def, onChange, onClose, popoverClassName = 'se-flow-settings-pop',
|
||||||
|
}: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDoc);
|
||||||
|
return () => document.removeEventListener('mousedown', onDoc);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const rightThreshold = getConditionThreshold(condition, 'right');
|
||||||
|
const showThreshold = hasEditableThreshold(condition) && rightThreshold != null;
|
||||||
|
const periodPresets = getPeriodPresetOptions(condition.indicatorType, def);
|
||||||
|
const thresholdPresets = getThresholdPresetOptions(condition.indicatorType);
|
||||||
|
const thresholdBounds = getThresholdBounds(condition.indicatorType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={popoverClassName}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="지표 설정"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="se-flow-settings-head">
|
||||||
|
<span>지표 설정</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-flow-settings-close"
|
||||||
|
aria-label="닫기"
|
||||||
|
title="닫기"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{condition.composite ? (
|
||||||
|
<>
|
||||||
|
<label className="se-flow-settings-field">
|
||||||
|
<span>{compositeElementLabel(condition.indicatorType, 1)} 기준값 (기간, 일)</span>
|
||||||
|
<ComboNumberInput
|
||||||
|
key={`composite-left-${condition.indicatorType}-${getConditionValuePeriod(condition, def)}`}
|
||||||
|
value={getConditionValuePeriod(condition, def)}
|
||||||
|
options={getCompositePeriodPresetOptions(condition.indicatorType, def, 'left')}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
onChange={p => onChange(setConditionValuePeriod(condition, p, def))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="se-flow-settings-field">
|
||||||
|
<span>{compositeElementLabel(condition.indicatorType, 2)} 기준값 (기간, 일)</span>
|
||||||
|
<ComboNumberInput
|
||||||
|
key={`composite-right-${condition.indicatorType}-${getConditionRightPeriod(condition, def)}`}
|
||||||
|
value={getConditionRightPeriod(condition, def)}
|
||||||
|
options={getCompositePeriodPresetOptions(condition.indicatorType, def, 'right')}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
onChange={p => onChange(setConditionRightPeriod(condition, p))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : usesValuePeriodField(condition) ? (
|
||||||
|
<label className="se-flow-settings-field">
|
||||||
|
<span>{condition.indicatorType} 기간 (일)</span>
|
||||||
|
<ComboNumberInput
|
||||||
|
value={getConditionValuePeriod(condition, def)}
|
||||||
|
options={periodPresets}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
onChange={p => onChange(setConditionValuePeriod(condition, p, def))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{showThreshold && rightThreshold != null && (
|
||||||
|
<label className="se-flow-settings-field">
|
||||||
|
<span>임계값</span>
|
||||||
|
<ComboNumberInput
|
||||||
|
value={rightThreshold}
|
||||||
|
options={thresholdPresets}
|
||||||
|
min={thresholdBounds.min}
|
||||||
|
max={thresholdBounds.max}
|
||||||
|
allowDecimal
|
||||||
|
onChange={v => onChange(setConditionThreshold(condition, 'right', v))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{condition.composite && (
|
||||||
|
<p className="se-flow-settings-hint">{compositeDisplayName(condition.indicatorType)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { memo, useCallback } from 'react';
|
import React, { memo, useCallback, useState } from 'react';
|
||||||
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
|
||||||
import { START_NODE_ID, type HandleSide, type StrategyFlowNodeData } from '../../utils/strategyFlowLayout';
|
import { START_NODE_ID, type HandleSide, type StrategyFlowNodeData } from '../../utils/strategyFlowLayout';
|
||||||
|
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||||
|
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||||
|
|
||||||
const LOGIC_COLORS: Record<string, string> = {
|
const LOGIC_COLORS: Record<string, string> = {
|
||||||
AND: '#00aaff',
|
AND: '#00aaff',
|
||||||
@@ -179,16 +181,23 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
|
|||||||
export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, selected }: NodeProps) {
|
export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, selected }: NodeProps) {
|
||||||
const d = data as StrategyFlowNodeData;
|
const d = data as StrategyFlowNodeData;
|
||||||
const node = d.logicNode!;
|
const node = d.logicNode!;
|
||||||
const ind = node.condition?.indicatorType ?? 'COND';
|
const cond = node.condition;
|
||||||
const condType = node.condition?.conditionType ?? '';
|
const ind = cond?.indicatorType ?? 'COND';
|
||||||
|
const condType = cond?.conditionType ?? '';
|
||||||
const isCross = ['CROSS_UP', 'CROSS_DOWN'].includes(condType);
|
const isCross = ['CROSS_UP', 'CROSS_DOWN'].includes(condType);
|
||||||
const tone = isCross ? 'cross' : 'value';
|
const tone = isCross ? 'cross' : 'value';
|
||||||
const isSell = d.signalTab === 'sell';
|
const isSell = d.signalTab === 'sell';
|
||||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const showSettings = cond && hasNodeSettings(cond);
|
||||||
|
|
||||||
|
const handleSettingsChange = useCallback((next: NonNullable<typeof cond>) => {
|
||||||
|
d.onUpdateCondition?.(id, next);
|
||||||
|
}, [d, id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`se-flow-node se-flow-node--cond se-flow-node--${tone}${isSell ? ' se-flow-node--sell-mode' : ''}${d.isOrphan ? ' se-flow-node--orphan' : ''} ${selected ? 'se-flow-node--selected' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}`}
|
className={`se-flow-node se-flow-node--cond se-flow-node--${tone}${isSell ? ' se-flow-node--sell-mode' : ''}${d.isOrphan ? ' se-flow-node--orphan' : ''} ${selected ? 'se-flow-node--selected' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}${settingsOpen ? ' se-flow-node--settings-open' : ''}`}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
@@ -198,9 +207,38 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
|
|||||||
targetOnly
|
targetOnly
|
||||||
canTargetConnect={d.canTargetConnect !== false}
|
canTargetConnect={d.canTargetConnect !== false}
|
||||||
/>
|
/>
|
||||||
|
<div className="se-flow-cond-top">
|
||||||
<div className="se-flow-cond-badge">{ind}</div>
|
<div className="se-flow-cond-badge">{ind}</div>
|
||||||
<div className="se-flow-cond-text">{d.label ?? ind}</div>
|
<div className="se-flow-cond-actions">
|
||||||
|
{showSettings && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="se-flow-settings"
|
||||||
|
title="지표 설정"
|
||||||
|
aria-label="지표 설정"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSettingsOpen(v => !v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="se-flow-cond-text">{d.label ?? ind}</div>
|
||||||
|
{settingsOpen && cond && d.def && (
|
||||||
|
<ConditionNodeSettings
|
||||||
|
condition={cond}
|
||||||
|
def={d.def}
|
||||||
|
onChange={handleSettingsChange}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface PaletteDragPayload {
|
|||||||
type: 'operator' | 'indicator';
|
type: 'operator' | 'indicator';
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
composite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,16 +14,17 @@ interface Props {
|
|||||||
desc?: string;
|
desc?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
period?: string;
|
period?: string;
|
||||||
|
composite?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PaletteChip({
|
export default function PaletteChip({
|
||||||
type, value, label, desc, color, period, selected = false, onSelect, onAdd,
|
type, value, label, desc, color, period, composite = false, selected = false, onSelect, onAdd,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const onDragStart = (e: React.DragEvent) => {
|
const onDragStart = (e: React.DragEvent) => {
|
||||||
const payload: PaletteDragPayload = { type, value, label };
|
const payload: PaletteDragPayload = { type, value, label, composite: composite || undefined };
|
||||||
e.dataTransfer.setData('application/json', JSON.stringify(payload));
|
e.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
};
|
};
|
||||||
@@ -51,7 +53,7 @@ export default function PaletteChip({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable
|
draggable
|
||||||
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}${selected ? ' se-palette-card--selected' : ''}`}
|
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}${composite ? ' se-palette-card--composite' : ''}${selected ? ' se-palette-card--selected' : ''}`}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
@@ -62,7 +64,9 @@ export default function PaletteChip({
|
|||||||
<div className="se-palette-card-head">
|
<div className="se-palette-card-head">
|
||||||
<span className="se-palette-card-icon">{label.slice(0, 1)}</span>
|
<span className="se-palette-card-icon">{label.slice(0, 1)}</span>
|
||||||
<div className="se-palette-card-meta">
|
<div className="se-palette-card-meta">
|
||||||
<span className="se-palette-card-name">{label}</span>
|
<span className="se-palette-card-name">
|
||||||
|
{composite ? `${label} + ${label}` : label}
|
||||||
|
</span>
|
||||||
{desc ? <span className="se-palette-card-desc">{desc}</span> : null}
|
{desc ? <span className="se-palette-card-desc">{desc}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ import {
|
|||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import type { Theme } from '../../types/index';
|
import type { Theme } from '../../types/index';
|
||||||
import type { LogicNode } from '../../utils/strategyTypes';
|
import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes';
|
||||||
import {
|
import {
|
||||||
addChild,
|
addChild,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
mergeAtRoot,
|
mergeAtRoot,
|
||||||
makeNode,
|
makeNode,
|
||||||
|
updateNode,
|
||||||
type DefType,
|
type DefType,
|
||||||
} from '../../utils/strategyEditorShared';
|
} from '../../utils/strategyEditorShared';
|
||||||
import {
|
import {
|
||||||
@@ -408,10 +409,10 @@ function StrategyEditorCanvasInner({
|
|||||||
}, [setNodes]);
|
}, [setNodes]);
|
||||||
|
|
||||||
const addOrphanAt = useCallback((
|
const addOrphanAt = useCallback((
|
||||||
data: { type: string; value: string; label: string },
|
data: { type: string; value: string; label: string; composite?: boolean },
|
||||||
flowPos: { x: number; y: number },
|
flowPos: { x: number; y: number },
|
||||||
) => {
|
) => {
|
||||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||||
positionsRef.current.set(newNode.id, {
|
positionsRef.current.set(newNode.id, {
|
||||||
x: flowPos.x - FLOW_NODE_W / 2,
|
x: flowPos.x - FLOW_NODE_W / 2,
|
||||||
y: flowPos.y - FLOW_NODE_H / 2,
|
y: flowPos.y - FLOW_NODE_H / 2,
|
||||||
@@ -422,10 +423,10 @@ function StrategyEditorCanvasInner({
|
|||||||
|
|
||||||
const applyDropWithAttach = useCallback((
|
const applyDropWithAttach = useCallback((
|
||||||
anchorId: string,
|
anchorId: string,
|
||||||
data: { type: string; value: string; label: string },
|
data: { type: string; value: string; label: string; composite?: boolean },
|
||||||
flowPos: { x: number; y: number },
|
flowPos: { x: number; y: number },
|
||||||
) => {
|
) => {
|
||||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||||
if (!anchor) return;
|
if (!anchor) return;
|
||||||
|
|
||||||
@@ -488,12 +489,24 @@ function StrategyEditorCanvasInner({
|
|||||||
}
|
}
|
||||||
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
|
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
|
||||||
|
|
||||||
|
const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => {
|
||||||
|
if (isOrphanNode(orphans, id)) {
|
||||||
|
onOrphansChange(orphans.map(o => (
|
||||||
|
o.id === id ? { ...o, condition } : o
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!root) return;
|
||||||
|
onChange(updateNode(root, id, n => ({ ...n, condition })));
|
||||||
|
}, [root, orphans, onChange, onOrphansChange]);
|
||||||
|
|
||||||
const flowCallbacks = useMemo(() => ({
|
const flowCallbacks = useMemo(() => ({
|
||||||
onDelete: handleDelete,
|
onDelete: handleDelete,
|
||||||
|
onUpdateCondition: handleUpdateCondition,
|
||||||
onDropTarget: handleDropTarget,
|
onDropTarget: handleDropTarget,
|
||||||
onDragOverTarget: handleDragOverTarget,
|
onDragOverTarget: handleDragOverTarget,
|
||||||
onDragLeaveTarget: handleDragLeaveTarget,
|
onDragLeaveTarget: handleDragLeaveTarget,
|
||||||
}), [handleDelete, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]);
|
}), [handleDelete, handleUpdateCondition, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]);
|
||||||
|
|
||||||
const rebuildFlow = useCallback(() => logicNodeToFlow(
|
const rebuildFlow = useCallback(() => logicNodeToFlow(
|
||||||
root,
|
root,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
nodeToText,
|
nodeToText,
|
||||||
type DefType,
|
type DefType,
|
||||||
} from '../../utils/strategyEditorShared';
|
} from '../../utils/strategyEditorShared';
|
||||||
|
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||||
|
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||||
|
|
||||||
const NODE_COLORS: Record<string, string> = {
|
const NODE_COLORS: Record<string, string> = {
|
||||||
AND: '#4caf50',
|
AND: '#4caf50',
|
||||||
@@ -41,6 +43,8 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const isLogic = ['AND', 'OR', 'NOT'].includes(node.type);
|
const isLogic = ['AND', 'OR', 'NOT'].includes(node.type);
|
||||||
const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR;
|
const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR;
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const showSettings = node.type === 'CONDITION' && node.condition && hasNodeSettings(node.condition);
|
||||||
const label = node.type === 'CONDITION' && node.condition
|
const label = node.type === 'CONDITION' && node.condition
|
||||||
? nodeToText(node, def)
|
? nodeToText(node, def)
|
||||||
: node.type === 'AND'
|
: node.type === 'AND'
|
||||||
@@ -83,13 +87,42 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<div className="sp-node-head" style={{ borderLeftColor: color }}>
|
<div className="sp-node-head sp-node-head--cond" style={{ borderLeftColor: color }}>
|
||||||
<span className="sp-node-badge" style={{ background: color }}>
|
<span className="sp-node-badge" style={{ background: color }}>
|
||||||
{node.type === 'CONDITION' && node.condition ? node.condition.indicatorType : node.type}
|
{node.type === 'CONDITION' && node.condition ? node.condition.indicatorType : node.type}
|
||||||
</span>
|
</span>
|
||||||
<span className="sp-node-label">{label}</span>
|
<span className="sp-node-label">{label}</span>
|
||||||
|
<div className="sp-node-actions">
|
||||||
|
{showSettings && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sp-node-settings"
|
||||||
|
title="지표 설정"
|
||||||
|
aria-label="지표 설정"
|
||||||
|
aria-expanded={settingsOpen}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSettingsOpen(v => !v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" className="sp-node-del" title="삭제" onClick={onDelete}>✕</button>
|
<button type="button" className="sp-node-del" title="삭제" onClick={onDelete}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
{settingsOpen && node.condition && (
|
||||||
|
<ConditionNodeSettings
|
||||||
|
condition={node.condition}
|
||||||
|
def={def}
|
||||||
|
onChange={handleCondChange}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
popoverClassName="sp-node-settings-pop"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{node.type === 'CONDITION' && node.condition && (
|
{node.type === 'CONDITION' && node.condition && (
|
||||||
<CondEditor cond={node.condition} signalType={signalType} onChange={handleCondChange} def={def} />
|
<CondEditor cond={node.condition} signalType={signalType} onChange={handleCondChange} def={def} />
|
||||||
@@ -132,9 +165,9 @@ export default function StrategyListEditor({ root, signalTab, def, onChange }: P
|
|||||||
|
|
||||||
const applyDrop = useCallback((
|
const applyDrop = useCallback((
|
||||||
targetId: string | null,
|
targetId: string | null,
|
||||||
data: { type: string; value: string; label: string },
|
data: { type: string; value: string; label: string; composite?: boolean },
|
||||||
) => {
|
) => {
|
||||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||||
if (!root) {
|
if (!root) {
|
||||||
onChange(newNode);
|
onChange(newNode);
|
||||||
return;
|
return;
|
||||||
@@ -156,7 +189,7 @@ export default function StrategyListEditor({ root, signalTab, def, onChange }: P
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string }) => {
|
const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string; composite?: boolean }) => {
|
||||||
applyDrop(targetId, data);
|
applyDrop(targetId, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -529,10 +529,41 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
container-type: size;
|
||||||
|
container-name: trade-card;
|
||||||
}
|
}
|
||||||
.ptd-order-card .ptd-order-stack { padding: 0 4px 4px; flex: 1; }
|
.ptd-order-card .ptd-order-stack { padding: 0 4px 4px; flex: 1; }
|
||||||
.ptd-order-card .top-panel { padding: 4px 2px; background: transparent; }
|
.ptd-order-card .top-panel { padding: 4px 2px; background: transparent; }
|
||||||
.ptd-order-card .top-field { margin-bottom: 4px; }
|
.ptd-order-card .top-field { margin-bottom: 4px; }
|
||||||
|
.ptd-order-card .top-price-qty-block .top-field { margin-bottom: 0; }
|
||||||
|
.ptd-order-card .top-price-qty-block .top-pct-row--block { margin-bottom: 4px; }
|
||||||
|
|
||||||
|
@container trade-card (max-height: 360px) {
|
||||||
|
.ptd-order-card .top-price-qty-block {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
column-gap: 8px;
|
||||||
|
row-gap: 4px;
|
||||||
|
}
|
||||||
|
.ptd-order-card .top-price-qty-block .top-field--price { grid-column: 1; grid-row: 1; }
|
||||||
|
.ptd-order-card .top-price-qty-block .top-field--qty { grid-column: 2; grid-row: 1; }
|
||||||
|
.ptd-order-card .top-price-qty-block .top-pct-row--block { grid-column: 1 / -1; grid-row: 2; margin-top: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 780px) {
|
||||||
|
@supports not (container-type: size) {
|
||||||
|
.ptd-order-card .top-price-qty-block {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
column-gap: 8px;
|
||||||
|
row-gap: 4px;
|
||||||
|
}
|
||||||
|
.ptd-order-card .top-price-qty-block .top-field--price { grid-column: 1; grid-row: 1; }
|
||||||
|
.ptd-order-card .top-price-qty-block .top-field--qty { grid-column: 2; grid-row: 1; }
|
||||||
|
.ptd-order-card .top-price-qty-block .top-pct-row--block { grid-column: 1 / -1; grid-row: 2; margin-top: 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ptd-order-card .top-submit--buy { background: #22c55e !important; border-color: #22c55e !important; }
|
.ptd-order-card .top-submit--buy { background: #22c55e !important; border-color: #22c55e !important; }
|
||||||
.ptd-order-card .top-submit--sell { background: var(--accent) !important; border-color: var(--accent) !important; }
|
.ptd-order-card .top-submit--sell { background: var(--accent) !important; border-color: var(--accent) !important; }
|
||||||
|
|
||||||
|
|||||||
@@ -702,7 +702,21 @@
|
|||||||
box-shadow: 0 0 16px color-mix(in srgb, var(--se-sell) 12%, transparent);
|
box-shadow: 0 0 16px color-mix(in srgb, var(--se-sell) 12%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.se-flow-cond-badge { font-size: 0.65rem; font-weight: 800; color: var(--se-success); margin-bottom: 4px; }
|
.se-flow-cond-badge { font-size: 0.65rem; font-weight: 800; color: var(--se-success); margin-bottom: 0; }
|
||||||
|
.se-flow-cond-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.se-flow-cond-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.se-flow-node--cond { position: relative; }
|
||||||
.se-flow-node--cross .se-flow-cond-badge { color: var(--se-gold); }
|
.se-flow-node--cross .se-flow-cond-badge { color: var(--se-gold); }
|
||||||
.se-flow-node--sell-mode .se-flow-cond-badge { color: var(--se-sell); }
|
.se-flow-node--sell-mode .se-flow-cond-badge { color: var(--se-sell); }
|
||||||
.se-flow-cond-text { font-size: 0.7rem; line-height: 1.35; color: var(--se-node-text); word-break: break-word; }
|
.se-flow-cond-text { font-size: 0.7rem; line-height: 1.35; color: var(--se-node-text); word-break: break-word; }
|
||||||
@@ -726,14 +740,108 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.se-flow-del {
|
.se-flow-del {
|
||||||
position: absolute; top: 4px; right: 6px;
|
position: static;
|
||||||
border: none; background: rgba(0, 0, 0, 0.4);
|
border: none; background: rgba(0, 0, 0, 0.4);
|
||||||
color: rgba(255, 77, 109, 0.9);
|
color: rgba(255, 77, 109, 0.9);
|
||||||
width: 18px; height: 18px; border-radius: 4px;
|
width: 18px; height: 18px; border-radius: 4px;
|
||||||
cursor: pointer; font-size: 0.85rem; line-height: 1;
|
cursor: pointer; font-size: 0.85rem; line-height: 1;
|
||||||
opacity: 0; transition: opacity 0.12s;
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.se-flow-settings {
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
color: var(--se-gold);
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s, background 0.12s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.se-flow-settings:hover {
|
||||||
|
background: rgba(230, 194, 0, 0.2);
|
||||||
|
}
|
||||||
|
.se-flow-node:hover .se-flow-del,
|
||||||
|
.se-flow-node:hover .se-flow-settings,
|
||||||
|
.se-flow-node--selected .se-flow-del,
|
||||||
|
.se-flow-node--selected .se-flow-settings,
|
||||||
|
.se-flow-node--settings-open .se-flow-del,
|
||||||
|
.se-flow-node--settings-open .se-flow-settings {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-flow-settings-pop {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
z-index: 30;
|
||||||
|
min-width: 148px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--se-gold) 45%, transparent);
|
||||||
|
background: var(--se-bg-elevated);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.se-flow-settings-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--se-gold);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.se-flow-settings-close {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.se-flow-settings-close:hover {
|
||||||
|
color: var(--se-gold);
|
||||||
|
}
|
||||||
|
.se-flow-settings-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
}
|
||||||
|
.se-flow-settings-field input,
|
||||||
|
.se-flow-settings-field .se-combo-num-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--se-input-border);
|
||||||
|
background: var(--se-input-bg);
|
||||||
|
color: var(--se-text);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.se-combo-num { width: 100%; }
|
||||||
|
.se-combo-num-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--se-input-focus);
|
||||||
|
}
|
||||||
|
.se-flow-settings-hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
color: var(--se-text-dim);
|
||||||
}
|
}
|
||||||
.se-flow-node:hover .se-flow-del { opacity: 1; }
|
|
||||||
|
|
||||||
.se-flow-handle {
|
.se-flow-handle {
|
||||||
width: 9px !important; height: 9px !important;
|
width: 9px !important; height: 9px !important;
|
||||||
@@ -907,8 +1015,8 @@
|
|||||||
|
|
||||||
/* ── Right palette ── */
|
/* ── Right palette ── */
|
||||||
.se-right {
|
.se-right {
|
||||||
flex: 0 0 260px;
|
flex: 0 0 380px;
|
||||||
width: 260px;
|
width: 380px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -952,7 +1060,8 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.se-palette-search {
|
.se-palette-search {
|
||||||
@@ -982,13 +1091,14 @@
|
|||||||
.se-palette-section--logic h3 { color: var(--se-palette-section-logic); }
|
.se-palette-section--logic h3 { color: var(--se-palette-section-logic); }
|
||||||
.se-palette-section--band h3 { color: var(--se-palette-section-band); }
|
.se-palette-section--band h3 { color: var(--se-palette-section-band); }
|
||||||
.se-palette-section--aux h3 { color: var(--se-palette-section-ind); }
|
.se-palette-section--aux h3 { color: var(--se-palette-section-ind); }
|
||||||
|
.se-palette-section--composite h3 { color: var(--se-palette-section-composite, #c084fc); }
|
||||||
|
|
||||||
.se-palette-grid {
|
.se-palette-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.se-palette-grid--3 { grid-template-columns: repeat(3, 1fr); }
|
.se-palette-grid--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
|
||||||
.se-palette-card {
|
.se-palette-card {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -1082,6 +1192,25 @@
|
|||||||
border-color: color-mix(in srgb, var(--se-palette-ind-accent) 65%, transparent);
|
border-color: color-mix(in srgb, var(--se-palette-ind-accent) 65%, transparent);
|
||||||
box-shadow: inset 3px 0 0 var(--se-palette-ind-accent), 0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent);
|
box-shadow: inset 3px 0 0 var(--se-palette-ind-accent), 0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 복합지표 ── */
|
||||||
|
.se-palette-card--composite {
|
||||||
|
border-color: color-mix(in srgb, #c084fc 42%, transparent);
|
||||||
|
background: color-mix(in srgb, #c084fc 8%, var(--se-palette-card-bg));
|
||||||
|
box-shadow: inset 3px 0 0 #c084fc;
|
||||||
|
}
|
||||||
|
.se-palette-card--composite .se-palette-card-icon {
|
||||||
|
background: color-mix(in srgb, #c084fc 22%, transparent);
|
||||||
|
color: #c084fc;
|
||||||
|
}
|
||||||
|
.se-palette-card--composite:hover {
|
||||||
|
border-color: color-mix(in srgb, #c084fc 65%, transparent);
|
||||||
|
box-shadow: inset 3px 0 0 #c084fc, 0 4px 14px color-mix(in srgb, #c084fc 22%, transparent);
|
||||||
|
}
|
||||||
|
.se-palette-card--selected.se-palette-card--composite {
|
||||||
|
outline-color: #c084fc;
|
||||||
|
}
|
||||||
|
|
||||||
.se-palette-card-head { display: flex; align-items: flex-start; gap: 6px; }
|
.se-palette-card-head { display: flex; align-items: flex-start; gap: 6px; }
|
||||||
.se-palette-card-icon {
|
.se-palette-card-icon {
|
||||||
width: 24px; height: 24px; border-radius: 6px;
|
width: 24px; height: 24px; border-radius: 6px;
|
||||||
@@ -1164,5 +1293,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.se-right { flex: 0 0 220px; width: 220px; }
|
.se-right { flex: 0 0 300px; width: 300px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,9 @@ export class ChartManager {
|
|||||||
/** 차트 하단 거래량 pane 표시 */
|
/** 차트 하단 거래량 pane 표시 */
|
||||||
private _volumeVisible = true;
|
private _volumeVisible = true;
|
||||||
|
|
||||||
|
/** applyPaneLayout / resetPaneHeights 에서 측정한 마지막 가용 높이(px) */
|
||||||
|
private _lastLayoutAvailableHeight?: number;
|
||||||
|
|
||||||
constructor(container: HTMLElement, theme: Theme) {
|
constructor(container: HTMLElement, theme: Theme) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
const t = getTheme(theme);
|
const t = getTheme(theme);
|
||||||
@@ -221,6 +224,7 @@ export class ChartManager {
|
|||||||
background: { type: ColorType.Solid, color: t.bgColor },
|
background: { type: ColorType.Solid, color: t.bgColor },
|
||||||
textColor: t.textColor,
|
textColor: t.textColor,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
panes: { enableResize: false },
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
vertLines: { color: t.gridColor },
|
vertLines: { color: t.gridColor },
|
||||||
@@ -288,6 +292,7 @@ export class ChartManager {
|
|||||||
}
|
}
|
||||||
this.indicators.clear();
|
this.indicators.clear();
|
||||||
this.patternMarkers = [];
|
this.patternMarkers = [];
|
||||||
|
this._removeExtraSubPanes();
|
||||||
|
|
||||||
this._disposeMainMarkersPlugin();
|
this._disposeMainMarkersPlugin();
|
||||||
if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
|
if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
|
||||||
@@ -535,6 +540,10 @@ export class ChartManager {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Indicator] ${config.type} error:`, e);
|
console.error(`[Indicator] ${config.type} error:`, e);
|
||||||
|
} finally {
|
||||||
|
if (this._activeIndicatorPaneIndices().size > 0) {
|
||||||
|
this.resetPaneHeights(this._lastLayoutAvailableHeight);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,6 +566,7 @@ export class ChartManager {
|
|||||||
this.indicators.clear();
|
this.indicators.clear();
|
||||||
this.patternMarkers = this.patternMarkers.filter(() => false);
|
this.patternMarkers = this.patternMarkers.filter(() => false);
|
||||||
this._reapplyAllPatternMarkers();
|
this._reapplyAllPatternMarkers();
|
||||||
|
this._removeExtraSubPanes();
|
||||||
|
|
||||||
// ② 새 순서로 재추가 (병합 호스트 → 멤버 순)
|
// ② 새 순서로 재추가 (병합 호스트 → 멤버 순)
|
||||||
this._indRunning = false;
|
this._indRunning = false;
|
||||||
@@ -590,7 +600,8 @@ export class ChartManager {
|
|||||||
this.patternMarkers = this.patternMarkers.filter(p => p.id !== id);
|
this.patternMarkers = this.patternMarkers.filter(p => p.id !== id);
|
||||||
this._reapplyAllPatternMarkers();
|
this._reapplyAllPatternMarkers();
|
||||||
this.indicators.delete(id);
|
this.indicators.delete(id);
|
||||||
this.resetPaneHeights();
|
this._removeExtraSubPanes();
|
||||||
|
this.resetPaneHeights(this._lastLayoutAvailableHeight);
|
||||||
this._notifyPaneLayout();
|
this._notifyPaneLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1232,6 +1243,7 @@ export class ChartManager {
|
|||||||
this.indicators.clear();
|
this.indicators.clear();
|
||||||
this.patternMarkers = [];
|
this.patternMarkers = [];
|
||||||
this._reapplyAllPatternMarkers();
|
this._reapplyAllPatternMarkers();
|
||||||
|
this._removeExtraSubPanes();
|
||||||
|
|
||||||
for (const cfg of configs) {
|
for (const cfg of configs) {
|
||||||
await this.addIndicator(cfg);
|
await this.addIndicator(cfg);
|
||||||
@@ -2047,6 +2059,20 @@ export class ChartManager {
|
|||||||
return indices;
|
return indices;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** pane 2+ 빈 sub-pane 제거 — 제거·재로드 후 고아 pane 이 높이를 나눠 가져가는 것 방지 */
|
||||||
|
private _removeExtraSubPanes(): void {
|
||||||
|
for (;;) {
|
||||||
|
const panes = this.chart.panes();
|
||||||
|
if (panes.length <= 2) return;
|
||||||
|
const last = panes.length - 1;
|
||||||
|
try {
|
||||||
|
this.chart.removePane(last);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** chart-container 기준 Y(px) → pane index (0=캔들, 1=거래량, 2+=보조지표) */
|
/** chart-container 기준 Y(px) → pane index (0=캔들, 1=거래량, 2+=보조지표) */
|
||||||
getPaneIndexAtChartY(chartY: number): number {
|
getPaneIndexAtChartY(chartY: number): number {
|
||||||
const layouts = this.getPaneLayouts();
|
const layouts = this.getPaneLayouts();
|
||||||
@@ -2374,6 +2400,16 @@ export class ChartManager {
|
|||||||
return this.indicators.size;
|
return this.indicators.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 실제 시리즈·구름이 붙은 보조지표 수 (부분 로드·중단 감지용) */
|
||||||
|
getLoadedIndicatorCount(): number {
|
||||||
|
let n = 0;
|
||||||
|
for (const entry of this.indicators.values()) {
|
||||||
|
if (entry.config?.hidden === true) continue;
|
||||||
|
if (entry.cloudPlugin || entry.seriesList.length > 0) n += 1;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
/** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */
|
/** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */
|
||||||
getRawBarsLength(): number {
|
getRawBarsLength(): number {
|
||||||
return this.rawBars.length;
|
return this.rawBars.length;
|
||||||
@@ -2732,12 +2768,16 @@ export class ChartManager {
|
|||||||
const N = activeIndPanes.size;
|
const N = activeIndPanes.size;
|
||||||
const H = (availableHeight && availableHeight > 0)
|
const H = (availableHeight && availableHeight > 0)
|
||||||
? availableHeight
|
? availableHeight
|
||||||
: this.container.clientHeight;
|
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
|
||||||
|
? this._lastLayoutAvailableHeight
|
||||||
|
: this.container.clientHeight);
|
||||||
|
|
||||||
const volumeShown = this._volumeVisible && !this._candleOnlyLayout;
|
const volumeShown = this._volumeVisible && !this._candleOnlyLayout;
|
||||||
const VOL_FRAC = this._volumeFrac(H);
|
const VOL_FRAC = this._volumeFrac(H);
|
||||||
const MIN_IND_PX = 80;
|
const MIN_IND_PX = 80;
|
||||||
const ORPHAN_STRETCH = 0.0001;
|
const ORPHAN_STRETCH = 0.0001;
|
||||||
|
/** 보조지표 pane 은 동일 stretch weight → 항상 같은 높이 */
|
||||||
|
const IND_EQUAL_WEIGHT = 1;
|
||||||
|
|
||||||
const remaining = Math.max(0, 1 - mainFrac - (volumeShown ? VOL_FRAC : 0));
|
const remaining = Math.max(0, 1 - mainFrac - (volumeShown ? VOL_FRAC : 0));
|
||||||
const eachIndFrac = N > 0 ? remaining / N : 0;
|
const eachIndFrac = N > 0 ? remaining / N : 0;
|
||||||
@@ -2746,13 +2786,20 @@ export class ChartManager {
|
|||||||
? eachIndFrac
|
? eachIndFrac
|
||||||
: MIN_IND_PX / H;
|
: MIN_IND_PX / H;
|
||||||
|
|
||||||
|
const indWeight = IND_EQUAL_WEIGHT;
|
||||||
|
const indUnit = Math.max(actualIndFrac, 1e-9);
|
||||||
|
const mainWeight = N > 0 ? mainFrac / indUnit : mainFrac;
|
||||||
|
const volWeight = volumeShown
|
||||||
|
? (N > 0 ? VOL_FRAC / indUnit : VOL_FRAC)
|
||||||
|
: ORPHAN_STRETCH;
|
||||||
|
|
||||||
for (let i = 0; i < panes.length; i++) {
|
for (let i = 0; i < panes.length; i++) {
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
panes[i].setStretchFactor(mainFrac);
|
panes[i].setStretchFactor(mainWeight);
|
||||||
} else if (i === 1) {
|
} else if (i === 1) {
|
||||||
panes[i].setStretchFactor(volumeShown ? VOL_FRAC : ORPHAN_STRETCH);
|
panes[i].setStretchFactor(volumeShown ? volWeight : ORPHAN_STRETCH);
|
||||||
} else if (activeIndPanes.has(i)) {
|
} else if (activeIndPanes.has(i)) {
|
||||||
panes[i].setStretchFactor(actualIndFrac);
|
panes[i].setStretchFactor(indWeight);
|
||||||
} else {
|
} else {
|
||||||
panes[i].setStretchFactor(ORPHAN_STRETCH);
|
panes[i].setStretchFactor(ORPHAN_STRETCH);
|
||||||
}
|
}
|
||||||
@@ -2801,13 +2848,19 @@ export class ChartManager {
|
|||||||
const panes = this.chart.panes();
|
const panes = this.chart.panes();
|
||||||
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
|
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
|
||||||
|
|
||||||
|
if (availableHeight != null && availableHeight > 0) {
|
||||||
|
this._lastLayoutAvailableHeight = availableHeight;
|
||||||
|
}
|
||||||
|
|
||||||
if (this._candleOnlyLayout) {
|
if (this._candleOnlyLayout) {
|
||||||
return this._resetPaneHeightsCandleFullscreen(availableHeight);
|
return this._resetPaneHeightsCandleFullscreen(availableHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
const H = (availableHeight && availableHeight > 0)
|
const H = (availableHeight && availableHeight > 0)
|
||||||
? availableHeight
|
? availableHeight
|
||||||
: this.container.clientHeight;
|
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
|
||||||
|
? this._lastLayoutAvailableHeight
|
||||||
|
: this.container.clientHeight);
|
||||||
const N = this._activeIndicatorPaneIndices().size;
|
const N = this._activeIndicatorPaneIndices().size;
|
||||||
const { mainFrac } = this._paneHeightFractions(N, this._volumeFrac(H));
|
const { mainFrac } = this._paneHeightFractions(N, this._volumeFrac(H));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/** 복합지표 — 동일 지표 2개(서로 다른 기간) 간 교차·비교 조건 */
|
||||||
|
import type { ConditionDSL } from './strategyTypes';
|
||||||
|
|
||||||
|
export interface CompositePeriodDef {
|
||||||
|
rsiPeriod: number;
|
||||||
|
cciPeriod: number;
|
||||||
|
adxPeriod: number;
|
||||||
|
williamsR: number;
|
||||||
|
trixPeriod: number;
|
||||||
|
vrPeriod: number;
|
||||||
|
newPsy: number;
|
||||||
|
investPsy: number;
|
||||||
|
dispShort: number;
|
||||||
|
dispMid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositeIndicatorItem {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 복합지표 팔레트 — 동일 지표 타입 2인스턴스 교차 */
|
||||||
|
export const COMPOSITE_INDICATOR_ITEMS: CompositeIndicatorItem[] = [
|
||||||
|
{ value: 'RSI', label: 'RSI', desc: 'RSI 기간 교차' },
|
||||||
|
{ value: 'CCI', label: 'CCI', desc: 'CCI 기간 교차' },
|
||||||
|
{ value: 'ADX', label: 'ADX', desc: 'ADX 기간 교차' },
|
||||||
|
{ value: 'WILLIAMS_R', label: 'Williams %R', desc: 'Williams %R 교차' },
|
||||||
|
{ value: 'TRIX', label: 'TRIX', desc: 'TRIX 기간 교차' },
|
||||||
|
{ value: 'VR', label: 'VR', desc: 'VR 기간 교차' },
|
||||||
|
{ value: 'PSYCHOLOGICAL', label: '심리도', desc: '심리도 기간 교차' },
|
||||||
|
{ value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: '투자심리도 교차' },
|
||||||
|
{ value: 'DISPARITY', label: '이격도', desc: '이격도 기간 교차' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getCompositeDefaultPeriods(ind: string, def: CompositePeriodDef): { short: number; long: number } {
|
||||||
|
switch (ind) {
|
||||||
|
case 'RSI': return { short: def.rsiPeriod, long: Math.max(def.rsiPeriod * 2, 20) };
|
||||||
|
case 'CCI': return { short: def.cciPeriod, long: Math.max(def.cciPeriod * 2, 26) };
|
||||||
|
case 'ADX': return { short: def.adxPeriod, long: Math.max(def.adxPeriod * 2, 28) };
|
||||||
|
case 'WILLIAMS_R': return { short: def.williamsR, long: Math.max(def.williamsR * 2, 28) };
|
||||||
|
case 'TRIX': return { short: def.trixPeriod, long: Math.max(def.trixPeriod * 2, 24) };
|
||||||
|
case 'VR': return { short: def.vrPeriod, long: Math.max(def.vrPeriod * 2, 20) };
|
||||||
|
case 'PSYCHOLOGICAL':
|
||||||
|
case 'NEW_PSYCHOLOGICAL': return { short: def.newPsy, long: Math.max(def.newPsy * 2, 24) };
|
||||||
|
case 'INVEST_PSYCHOLOGICAL': return { short: def.investPsy, long: Math.max(def.investPsy * 2, 20) };
|
||||||
|
case 'DISPARITY': return { short: def.dispShort, long: def.dispMid };
|
||||||
|
default: return { short: 9, long: 20 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compositeValueField(ind: string, period: number): string {
|
||||||
|
switch (ind) {
|
||||||
|
case 'RSI': return `RSI_VALUE_${period}`;
|
||||||
|
case 'CCI': return `CCI_VALUE_${period}`;
|
||||||
|
case 'ADX': return `ADX_VALUE_${period}`;
|
||||||
|
case 'WILLIAMS_R': return `WILLIAMS_R_VALUE_${period}`;
|
||||||
|
case 'TRIX': return `TRIX_VALUE_${period}`;
|
||||||
|
case 'VR': return `VR_VALUE_${period}`;
|
||||||
|
case 'PSYCHOLOGICAL':
|
||||||
|
case 'NEW_PSYCHOLOGICAL': return `PSY_VALUE_${period}`;
|
||||||
|
case 'INVEST_PSYCHOLOGICAL': return `INVEST_PSY_VALUE_${period}`;
|
||||||
|
case 'DISPARITY': return `DISPARITY${period}`;
|
||||||
|
default: return `${ind}_VALUE_${period}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePeriodFromCompositeField(ind: string, field?: string): number | null {
|
||||||
|
if (!field) return null;
|
||||||
|
if (ind === 'DISPARITY' && field.startsWith('DISPARITY')) {
|
||||||
|
const n = parseInt(field.slice('DISPARITY'.length), 10);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
const suffix = field.match(/_(\d+)$/);
|
||||||
|
if (!suffix) return null;
|
||||||
|
const n = parseInt(suffix[1], 10);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncCompositeFields(cond: ConditionDSL): ConditionDSL {
|
||||||
|
if (!cond.composite) return cond;
|
||||||
|
const leftP = cond.leftPeriod
|
||||||
|
?? parsePeriodFromCompositeField(cond.indicatorType, cond.leftField)
|
||||||
|
?? undefined;
|
||||||
|
const rightP = cond.rightPeriod
|
||||||
|
?? parsePeriodFromCompositeField(cond.indicatorType, cond.rightField)
|
||||||
|
?? undefined;
|
||||||
|
if (leftP == null || rightP == null) {
|
||||||
|
return {
|
||||||
|
...cond,
|
||||||
|
composite: true,
|
||||||
|
...(leftP != null ? { leftPeriod: leftP } : null),
|
||||||
|
...(rightP != null ? { rightPeriod: rightP } : null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...cond,
|
||||||
|
composite: true,
|
||||||
|
leftPeriod: leftP,
|
||||||
|
rightPeriod: rightP,
|
||||||
|
leftField: compositeValueField(cond.indicatorType, leftP),
|
||||||
|
rightField: compositeValueField(cond.indicatorType, rightP),
|
||||||
|
period: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
|
||||||
|
if (cond.composite) return syncCompositeFields(cond);
|
||||||
|
const leftP = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
|
||||||
|
const rightP = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField);
|
||||||
|
if (leftP && rightP && leftP !== rightP) {
|
||||||
|
return syncCompositeFields({
|
||||||
|
...cond,
|
||||||
|
composite: true,
|
||||||
|
leftPeriod: leftP,
|
||||||
|
rightPeriod: rightP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cond;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeCompositeCondition(
|
||||||
|
ind: string,
|
||||||
|
signalType: 'buy' | 'sell',
|
||||||
|
def: CompositePeriodDef,
|
||||||
|
): ConditionDSL {
|
||||||
|
const { short, long } = getCompositeDefaultPeriods(ind, def);
|
||||||
|
return syncCompositeFields({
|
||||||
|
indicatorType: ind,
|
||||||
|
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
|
||||||
|
composite: true,
|
||||||
|
leftPeriod: short,
|
||||||
|
rightPeriod: long,
|
||||||
|
candleRange: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compositePeriodLabel(ind: string, def: CompositePeriodDef): string {
|
||||||
|
const { short, long } = getCompositeDefaultPeriods(ind, def);
|
||||||
|
return `${short} / ${long}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compositeDisplayName(ind: string): string {
|
||||||
|
const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind);
|
||||||
|
if (item) return `${item.label} + ${item.label}`;
|
||||||
|
return `${ind} + ${ind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compositeElementLabel(ind: string, slot: 1 | 2): string {
|
||||||
|
const item = COMPOSITE_INDICATOR_ITEMS.find(i => i.value === ind);
|
||||||
|
const name = item?.label ?? ind;
|
||||||
|
return `${name} ${slot}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
/** 조건 노드별 지표 기간·임계값 — 차트 DEF 기본값 대비 노드 단위 오버라이드 */
|
||||||
|
import type { ConditionDSL } from './strategyTypes';
|
||||||
|
import { getCompositeDefaultPeriods, parsePeriodFromCompositeField, syncCompositeFields, type CompositePeriodDef } from './compositeIndicators';
|
||||||
|
|
||||||
|
export interface IndicatorPeriodDef {
|
||||||
|
rsiPeriod: number;
|
||||||
|
cciPeriod: number;
|
||||||
|
adxPeriod: number;
|
||||||
|
williamsR: number;
|
||||||
|
trixPeriod: number;
|
||||||
|
vrPeriod: number;
|
||||||
|
newPsy: number;
|
||||||
|
investPsy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALUE_FIELD_PREFIX: Record<string, string> = {
|
||||||
|
RSI: 'RSI_VALUE',
|
||||||
|
CCI: 'CCI_VALUE',
|
||||||
|
ADX: 'ADX_VALUE',
|
||||||
|
WILLIAMS_R: 'WILLIAMS_R_VALUE',
|
||||||
|
TRIX: 'TRIX_VALUE',
|
||||||
|
VR: 'VR_VALUE',
|
||||||
|
PSYCHOLOGICAL: 'PSY_VALUE',
|
||||||
|
NEW_PSYCHOLOGICAL: 'PSY_VALUE',
|
||||||
|
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultIndicatorPeriod(indicatorType: string, def: IndicatorPeriodDef): number {
|
||||||
|
switch (indicatorType) {
|
||||||
|
case 'RSI': return def.rsiPeriod;
|
||||||
|
case 'CCI': return def.cciPeriod;
|
||||||
|
case 'ADX': return def.adxPeriod;
|
||||||
|
case 'WILLIAMS_R': return def.williamsR;
|
||||||
|
case 'TRIX': return def.trixPeriod;
|
||||||
|
case 'VR': return def.vrPeriod;
|
||||||
|
case 'PSYCHOLOGICAL':
|
||||||
|
case 'NEW_PSYCHOLOGICAL': return def.newPsy;
|
||||||
|
case 'INVEST_PSYCHOLOGICAL': return def.investPsy;
|
||||||
|
default: return 14;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFieldPeriod(field?: string): number | null {
|
||||||
|
if (!field) return null;
|
||||||
|
const m = field.match(/_(\d+)$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const n = parseInt(m[1], 10);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 조건 노드의 RSI 등 값(좌측) 기간 */
|
||||||
|
export function getConditionValuePeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
|
||||||
|
if (cond.composite) {
|
||||||
|
if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod;
|
||||||
|
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
|
||||||
|
if (fromField) return fromField;
|
||||||
|
}
|
||||||
|
if (cond.period && cond.period > 0) return cond.period;
|
||||||
|
const fromField = parseFieldPeriod(cond.leftField);
|
||||||
|
if (fromField) return fromField;
|
||||||
|
return getDefaultIndicatorPeriod(cond.indicatorType, def);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConditionRightPeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
|
||||||
|
if (cond.composite) {
|
||||||
|
if (cond.rightPeriod && cond.rightPeriod > 0) return cond.rightPeriod;
|
||||||
|
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField);
|
||||||
|
if (fromField) return fromField;
|
||||||
|
}
|
||||||
|
const fromField = parseFieldPeriod(cond.rightField);
|
||||||
|
if (fromField) return fromField;
|
||||||
|
return getDefaultIndicatorPeriod(cond.indicatorType, def);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usesValuePeriodField(cond: ConditionDSL): boolean {
|
||||||
|
if (cond.composite) return true;
|
||||||
|
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
|
||||||
|
if (!prefix) return false;
|
||||||
|
const lf = cond.leftField ?? '';
|
||||||
|
return lf === prefix || lf.startsWith(`${prefix}_`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseThresholdField(field?: string): number | null {
|
||||||
|
if (!field?.startsWith('K_')) return null;
|
||||||
|
const n = parseFloat(field.slice(2));
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function thresholdField(value: number): string {
|
||||||
|
return `K_${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasEditableThreshold(cond: ConditionDSL): boolean {
|
||||||
|
return parseThresholdField(cond.rightField) != null
|
||||||
|
|| parseThresholdField(cond.leftField) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConditionThreshold(cond: ConditionDSL, side: 'left' | 'right'): number | null {
|
||||||
|
const field = side === 'left' ? cond.leftField : cond.rightField;
|
||||||
|
return parseThresholdField(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConditionValuePeriod(
|
||||||
|
cond: ConditionDSL,
|
||||||
|
period: number,
|
||||||
|
_def: IndicatorPeriodDef,
|
||||||
|
): ConditionDSL {
|
||||||
|
const p = Math.max(1, Math.min(500, Math.round(period)));
|
||||||
|
if (cond.composite) {
|
||||||
|
return syncCompositeFields({ ...cond, composite: true, leftPeriod: p });
|
||||||
|
}
|
||||||
|
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
|
||||||
|
const next: ConditionDSL = { ...cond, period: p };
|
||||||
|
if (prefix && (cond.leftField === prefix || cond.leftField?.startsWith(`${prefix}_`))) {
|
||||||
|
next.leftField = `${prefix}_${p}`;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConditionRightPeriod(cond: ConditionDSL, period: number): ConditionDSL {
|
||||||
|
const p = Math.max(1, Math.min(500, Math.round(period)));
|
||||||
|
if (!cond.composite) return cond;
|
||||||
|
return syncCompositeFields({ ...cond, composite: true, rightPeriod: p });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConditionThreshold(
|
||||||
|
cond: ConditionDSL,
|
||||||
|
side: 'left' | 'right',
|
||||||
|
value: number,
|
||||||
|
): ConditionDSL {
|
||||||
|
const fieldKey = side === 'left' ? 'leftField' : 'rightField';
|
||||||
|
const current = cond[fieldKey];
|
||||||
|
if (!current?.startsWith('K_')) return cond;
|
||||||
|
return {
|
||||||
|
...cond,
|
||||||
|
[fieldKey]: thresholdField(value),
|
||||||
|
targetValue: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initConditionPeriods(
|
||||||
|
indicatorType: string,
|
||||||
|
condition: ConditionDSL,
|
||||||
|
def: IndicatorPeriodDef,
|
||||||
|
): ConditionDSL {
|
||||||
|
if (condition.composite) return condition;
|
||||||
|
const prefix = VALUE_FIELD_PREFIX[indicatorType];
|
||||||
|
if (!prefix) return condition;
|
||||||
|
const period = getDefaultIndicatorPeriod(indicatorType, def);
|
||||||
|
const lf = condition.leftField ?? '';
|
||||||
|
if (lf === prefix || lf.startsWith(`${prefix}_`)) {
|
||||||
|
return { ...condition, period: condition.period ?? period, leftField: `${prefix}_${condition.period ?? period}` };
|
||||||
|
}
|
||||||
|
return { ...condition, period: condition.period ?? period };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasNodeSettings(cond: ConditionDSL): boolean {
|
||||||
|
return usesValuePeriodField(cond)
|
||||||
|
|| hasEditableThreshold(cond)
|
||||||
|
|| !!cond.composite;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMON_PERIODS = [5, 7, 9, 10, 12, 13, 14, 20, 21, 26, 28, 50, 60, 120];
|
||||||
|
|
||||||
|
export function getPeriodPresetOptions(indicatorType: string, def: IndicatorPeriodDef): number[] {
|
||||||
|
const base = getDefaultIndicatorPeriod(indicatorType, def);
|
||||||
|
return [...new Set([base, ...COMMON_PERIODS])].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 복합지표 요소1(단기)·요소2(장기) 기간 프리셋 */
|
||||||
|
export function getCompositePeriodPresetOptions(
|
||||||
|
indicatorType: string,
|
||||||
|
def: IndicatorPeriodDef,
|
||||||
|
side: 'left' | 'right',
|
||||||
|
): number[] {
|
||||||
|
const { short, long } = getCompositeDefaultPeriods(indicatorType, def as CompositePeriodDef);
|
||||||
|
const base = side === 'left' ? short : long;
|
||||||
|
return [...new Set([base, short, long, ...COMMON_PERIODS])].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThresholdPresetOptions(indicatorType: string): number[] {
|
||||||
|
switch (indicatorType) {
|
||||||
|
case 'RSI':
|
||||||
|
case 'PSYCHOLOGICAL':
|
||||||
|
case 'NEW_PSYCHOLOGICAL':
|
||||||
|
case 'INVEST_PSYCHOLOGICAL':
|
||||||
|
return [20, 25, 30, 50, 70, 75, 80];
|
||||||
|
case 'STOCHASTIC':
|
||||||
|
return [20, 50, 80];
|
||||||
|
case 'CCI':
|
||||||
|
return [-200, -100, -50, 0, 50, 100, 150, 200];
|
||||||
|
case 'WILLIAMS_R':
|
||||||
|
return [-80, -50, -20];
|
||||||
|
case 'ADX':
|
||||||
|
case 'DMI':
|
||||||
|
return [20, 25, 30, 40, 50];
|
||||||
|
case 'VR':
|
||||||
|
return [70, 100, 150, 200, 250];
|
||||||
|
default:
|
||||||
|
return [-100, -50, 0, 50, 100];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThresholdBounds(indicatorType: string): { min: number; max: number } {
|
||||||
|
switch (indicatorType) {
|
||||||
|
case 'RSI':
|
||||||
|
case 'PSYCHOLOGICAL':
|
||||||
|
case 'NEW_PSYCHOLOGICAL':
|
||||||
|
case 'INVEST_PSYCHOLOGICAL':
|
||||||
|
case 'STOCHASTIC':
|
||||||
|
return { min: 0, max: 100 };
|
||||||
|
case 'WILLIAMS_R':
|
||||||
|
return { min: -100, max: 0 };
|
||||||
|
case 'CCI':
|
||||||
|
return { min: -500, max: 500 };
|
||||||
|
default:
|
||||||
|
return { min: -1000, max: 1000 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,24 @@ import React from 'react';
|
|||||||
import type { IndicatorConfig } from '../types/index';
|
import type { IndicatorConfig } from '../types/index';
|
||||||
import { getIndicatorDef, getHLineLabel } from '../utils/indicatorRegistry';
|
import { getIndicatorDef, getHLineLabel } from '../utils/indicatorRegistry';
|
||||||
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
|
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
|
||||||
|
import {
|
||||||
|
compositeDisplayName,
|
||||||
|
compositeElementLabel,
|
||||||
|
makeCompositeCondition,
|
||||||
|
normalizeCompositeCondition,
|
||||||
|
syncCompositeFields,
|
||||||
|
} from '../utils/compositeIndicators';
|
||||||
|
import ComboNumberInput from '../components/strategyEditor/ComboNumberInput';
|
||||||
|
import {
|
||||||
|
getCompositePeriodPresetOptions,
|
||||||
|
getConditionRightPeriod,
|
||||||
|
getConditionValuePeriod,
|
||||||
|
getDefaultIndicatorPeriod,
|
||||||
|
initConditionPeriods,
|
||||||
|
parseThresholdField,
|
||||||
|
setConditionRightPeriod,
|
||||||
|
setConditionValuePeriod,
|
||||||
|
} from '../utils/conditionPeriods';
|
||||||
|
|
||||||
export interface StrategyDto {
|
export interface StrategyDto {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -76,11 +94,14 @@ const DEF_DEFAULTS = {
|
|||||||
/**
|
/**
|
||||||
* activeIndicators에서 파라미터를 추출해 DEF를 구성.
|
* activeIndicators에서 파라미터를 추출해 DEF를 구성.
|
||||||
* 레지스트리 type명 / params 키는 indicatorRegistry.ts 의 defaultParams 기준.
|
* 레지스트리 type명 / params 키는 indicatorRegistry.ts 의 defaultParams 기준.
|
||||||
|
* (레거시 투자전략 화면용 — 차트 활성 지표와 동기화)
|
||||||
*/
|
*/
|
||||||
export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
|
export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
|
||||||
// 레지스트리 type명으로 조회
|
// params 객체 참조 공유 방지
|
||||||
const p = (registryType: string) =>
|
const p = (registryType: string) => {
|
||||||
activeIndicators.find(i => i.type === registryType)?.params ?? {};
|
const raw = activeIndicators.find(i => i.type === registryType)?.params;
|
||||||
|
return raw ? { ...raw } : {};
|
||||||
|
};
|
||||||
const num = (params: Record<string, number | string | boolean>, key: string, fallback: number) =>
|
const num = (params: Record<string, number | string | boolean>, key: string, fallback: number) =>
|
||||||
typeof params[key] === 'number' ? (params[key] as number) : fallback;
|
typeof params[key] === 'number' ? (params[key] as number) : fallback;
|
||||||
|
|
||||||
@@ -208,6 +229,14 @@ export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전략편집기 전용 DEF — 차트 activeIndicators와 무관한 고정 기본값.
|
||||||
|
* 조건 노드의 기간·임계값은 ConditionDSL(leftPeriod/rightPeriod/period)에만 저장된다.
|
||||||
|
*/
|
||||||
|
export function buildStrategyEditorDef(): DefType {
|
||||||
|
return buildDef([]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* hline 임계값을 K_ 접두어 DSL 필드값으로 변환.
|
* hline 임계값을 K_ 접두어 DSL 필드값으로 변환.
|
||||||
* 예: 70 → "K_70", -100 → "K_-100"
|
* 예: 70 → "K_70", -100 → "K_-100"
|
||||||
@@ -251,7 +280,12 @@ const ICHIMOKU_CONDS = [
|
|||||||
type Opt = { value: string; label: string };
|
type Opt = { value: string; label: string };
|
||||||
const NONE: Opt = { value: 'NONE', label: '선택안함' };
|
const NONE: Opt = { value: 'NONE', label: '선택안함' };
|
||||||
|
|
||||||
export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType): Opt[] => {
|
export const getFieldOpts = (
|
||||||
|
ind: string,
|
||||||
|
signalType: 'buy'|'sell',
|
||||||
|
DEF: DefType,
|
||||||
|
cond?: ConditionDSL,
|
||||||
|
): Opt[] => {
|
||||||
const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈';
|
const overTerm = signalType === 'buy' ? '과열진입' : '과열이탈';
|
||||||
const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
|
const underTerm = signalType === 'buy' ? '침체이탈' : '침체진입';
|
||||||
const condOpts = (conds: Opt[]): Opt[] => [NONE, ...conds];
|
const condOpts = (conds: Opt[]): Opt[] => [NONE, ...conds];
|
||||||
@@ -269,8 +303,9 @@ export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType
|
|||||||
switch(ind) {
|
switch(ind) {
|
||||||
case 'RSI': {
|
case 'RSI': {
|
||||||
const { over, mid, under } = th({ over:70, mid:50, under:30 });
|
const { over, mid, under } = th({ over:70, mid:50, under:30 });
|
||||||
|
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
|
||||||
return condOpts([
|
return condOpts([
|
||||||
{ value:'RSI_VALUE', label:`RSI 값(${DEF.rsiPeriod}일)` },
|
{ value:'RSI_VALUE', label:`RSI 값(${rsiP}일)` },
|
||||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||||
@@ -288,8 +323,9 @@ export const getFieldOpts = (ind: string, signalType: 'buy'|'sell', DEF: DefType
|
|||||||
}
|
}
|
||||||
case 'CCI': {
|
case 'CCI': {
|
||||||
const { over, mid, under } = th({ over:100, mid:0, under:-100 });
|
const { over, mid, under } = th({ over:100, mid:0, under:-100 });
|
||||||
|
const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod;
|
||||||
return condOpts([
|
return condOpts([
|
||||||
{ value:'CCI_VALUE', label:`CCI 값(${DEF.cciPeriod}일)` },
|
{ value:'CCI_VALUE', label:`CCI 값(${cciP}일)` },
|
||||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||||
@@ -483,6 +519,14 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType):
|
|||||||
// conditionType 변경 시 자동 필드 조정
|
// conditionType 변경 시 자동 필드 조정
|
||||||
const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => {
|
const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => {
|
||||||
const updated = { ...cond, conditionType: newCondType };
|
const updated = { ...cond, conditionType: newCondType };
|
||||||
|
if (cond.composite) {
|
||||||
|
return syncCompositeFields({
|
||||||
|
...updated,
|
||||||
|
composite: true,
|
||||||
|
leftPeriod: cond.leftPeriod,
|
||||||
|
rightPeriod: cond.rightPeriod,
|
||||||
|
});
|
||||||
|
}
|
||||||
const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF);
|
const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF);
|
||||||
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
||||||
const def = getDefaultFields(cond.indicatorType, 'buy', DEF);
|
const def = getDefaultFields(cond.indicatorType, 'buy', DEF);
|
||||||
@@ -516,14 +560,40 @@ const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: Def
|
|||||||
// ─── 자연어 변환 ──────────────────────────────────────────────────────────────
|
// ─── 자연어 변환 ──────────────────────────────────────────────────────────────
|
||||||
const condLabel = (v: string) => COND_OPTIONS.concat(ICHIMOKU_CONDS as any).find(o => o.v === v)?.l ?? v;
|
const condLabel = (v: string) => COND_OPTIONS.concat(ICHIMOKU_CONDS as any).find(o => o.v === v)?.l ?? v;
|
||||||
|
|
||||||
|
/** RSI_VALUE_9 → RSI_VALUE (옵션 조회용) */
|
||||||
|
export function resolveFieldOptionValue(indicatorType: string, field?: string): string | undefined {
|
||||||
|
if (!field) return field;
|
||||||
|
const bases: Record<string, string> = {
|
||||||
|
RSI: 'RSI_VALUE',
|
||||||
|
CCI: 'CCI_VALUE',
|
||||||
|
ADX: 'ADX_VALUE',
|
||||||
|
WILLIAMS_R: 'WILLIAMS_R_VALUE',
|
||||||
|
TRIX: 'TRIX_VALUE',
|
||||||
|
VR: 'VR_VALUE',
|
||||||
|
PSYCHOLOGICAL: 'PSY_VALUE',
|
||||||
|
NEW_PSYCHOLOGICAL: 'PSY_VALUE',
|
||||||
|
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
|
||||||
|
};
|
||||||
|
const base = bases[indicatorType];
|
||||||
|
if (base && (field === base || field.startsWith(`${base}_`))) return base;
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => {
|
export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string => {
|
||||||
if (!node) return '';
|
if (!node) return '';
|
||||||
if (node.type === 'CONDITION' && node.condition) {
|
if (node.type === 'CONDITION' && node.condition) {
|
||||||
const c = node.condition;
|
const c = normalizeCompositeCondition(node.condition);
|
||||||
const opts = getFieldOpts(c.indicatorType, 'buy', DEF);
|
const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c);
|
||||||
const L = opts.find(o => o.value === c.leftField)?.label ?? c.leftField ?? c.indicatorType;
|
|
||||||
const R = opts.find(o => o.value === c.rightField)?.label ?? c.rightField ?? '';
|
|
||||||
const C = condLabel(c.conditionType);
|
const C = condLabel(c.conditionType);
|
||||||
|
if (c.composite && c.leftPeriod && c.rightPeriod) {
|
||||||
|
const name = compositeDisplayName(c.indicatorType).split(' + ')[0];
|
||||||
|
return `${c.indicatorType} - ${name}(${c.leftPeriod}) ${C} ${name}(${c.rightPeriod})`;
|
||||||
|
}
|
||||||
|
const lv = resolveFieldOptionValue(c.indicatorType, c.leftField);
|
||||||
|
const rv = resolveFieldOptionValue(c.indicatorType, c.rightField);
|
||||||
|
const L = opts.find(o => o.value === lv)?.label ?? c.leftField ?? c.indicatorType;
|
||||||
|
const R = opts.find(o => o.value === rv)?.label
|
||||||
|
?? (rv && parseThresholdField(rv) != null ? `임계(${parseThresholdField(rv)})` : rv ?? '');
|
||||||
if (R && R !== '선택안함') return `${c.indicatorType} - ${L} ${C} ${R}`;
|
if (R && R !== '선택안함') return `${c.indicatorType} - ${L} ${C} ${R}`;
|
||||||
return `${c.indicatorType} - ${L} ${C}`;
|
return `${c.indicatorType} - ${L} ${C}`;
|
||||||
}
|
}
|
||||||
@@ -624,24 +694,30 @@ interface CondEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChange, def }) => {
|
export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChange, def }) => {
|
||||||
const fieldOpts = getFieldOpts(cond.indicatorType, signalType, def);
|
const normalized = normalizeCompositeCondition(cond);
|
||||||
const condOpts = cond.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
|
const fieldOpts = getFieldOpts(normalized.indicatorType, signalType, def, normalized);
|
||||||
|
const condOpts = normalized.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
|
||||||
|
|
||||||
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
||||||
const defaults = getDefaultFields(cond.indicatorType, signalType, def);
|
const defaults = getDefaultFields(normalized.indicatorType, signalType, def);
|
||||||
|
|
||||||
const getLeftValue = () => isValid(cond.leftField) ? cond.leftField! : defaults.l;
|
const getLeftValue = () => {
|
||||||
const getRightValue = () => isValid(cond.rightField) ? cond.rightField! : defaults.r;
|
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField);
|
||||||
|
return isValid(v) ? v! : defaults.l;
|
||||||
|
};
|
||||||
|
const getRightValue = () => {
|
||||||
|
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.rightField);
|
||||||
|
return isValid(v) ? v! : defaults.r;
|
||||||
|
};
|
||||||
|
|
||||||
const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(cond.conditionType);
|
const isSlopeType = ['SLOPE_UP','SLOPE_DOWN'].includes(normalized.conditionType);
|
||||||
const isHoldType = cond.conditionType === 'HOLD_N_DAYS';
|
const isHoldType = normalized.conditionType === 'HOLD_N_DAYS';
|
||||||
const isDiffType = ['DIFF_GT','DIFF_LT'].includes(cond.conditionType);
|
const isDiffType = ['DIFF_GT','DIFF_LT'].includes(normalized.conditionType);
|
||||||
|
|
||||||
const rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
|
const rightValue = isSlopeType ? 'NONE' : isHoldType ? 'NONE' : getRightValue();
|
||||||
|
|
||||||
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(cond, newType, def));
|
const handleCondType = (newType: string) => onChange(applyCondTypeDefaults(normalized, newType, def));
|
||||||
const handleRight = (v: string) => {
|
const handleRight = (v: string) => {
|
||||||
// rightField 변경 시 임계값 자동 설정
|
|
||||||
const thresholds: Record<string,number> = {
|
const thresholds: Record<string,number> = {
|
||||||
OVERBOUGHT_70: 70, NEUTRAL_50: 50, OVERSOLD_30: 30,
|
OVERBOUGHT_70: 70, NEUTRAL_50: 50, OVERSOLD_30: 30,
|
||||||
OVERBOUGHT_80: 80, OVERSOLD_20: 20,
|
OVERBOUGHT_80: 80, OVERSOLD_20: 20,
|
||||||
@@ -652,11 +728,62 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
|||||||
OVERBOUGHT_200: 200, NEUTRAL_100: 100, OVERSOLD_50: 50,
|
OVERBOUGHT_200: 200, NEUTRAL_100: 100, OVERSOLD_50: 50,
|
||||||
ZERO_LINE: 0,
|
ZERO_LINE: 0,
|
||||||
};
|
};
|
||||||
const upd: ConditionDSL = { ...cond, rightField: v };
|
const upd: ConditionDSL = { ...normalized, rightField: v };
|
||||||
if (thresholds[v] !== undefined) upd.targetValue = thresholds[v];
|
if (thresholds[v] !== undefined) upd.targetValue = thresholds[v];
|
||||||
onChange(upd);
|
onChange(upd);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (normalized.composite) {
|
||||||
|
const leftPeriod = getConditionValuePeriod(normalized, def);
|
||||||
|
const rightPeriod = getConditionRightPeriod(normalized, def);
|
||||||
|
return (
|
||||||
|
<div className="sp-cond-editor sp-cond-editor--composite">
|
||||||
|
<div className="sp-cond-composite-badge">복합지표 · {compositeDisplayName(normalized.indicatorType)}</div>
|
||||||
|
<div className="sp-cond-row4">
|
||||||
|
<div className="sp-cond-field">
|
||||||
|
<label className="sp-cond-lbl">캔들 범위</label>
|
||||||
|
<select className="sp-cond-sel" value={normalized.candleRange ?? 1}
|
||||||
|
onChange={e => onChange({ ...normalized, candleRange: Number(e.target.value) })}>
|
||||||
|
<option value={1}>현재 캔들</option>
|
||||||
|
<option value={2}>2개 캔들</option>
|
||||||
|
<option value={3}>3개 캔들</option>
|
||||||
|
<option value={4}>4개 캔들</option>
|
||||||
|
<option value={5}>5개 캔들</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="sp-cond-field">
|
||||||
|
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 1)} 기준값 (기간)</label>
|
||||||
|
<ComboNumberInput
|
||||||
|
value={leftPeriod}
|
||||||
|
options={getCompositePeriodPresetOptions(normalized.indicatorType, def, 'left')}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
onChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sp-cond-field">
|
||||||
|
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 2)} 기준값 (기간)</label>
|
||||||
|
<ComboNumberInput
|
||||||
|
value={rightPeriod}
|
||||||
|
options={getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right')}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
onChange={p => onChange(setConditionRightPeriod(normalized, p))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sp-cond-field">
|
||||||
|
<label className="sp-cond-lbl">조건</label>
|
||||||
|
<select className="sp-cond-sel"
|
||||||
|
value={normalized.conditionType}
|
||||||
|
onChange={e => handleCondType(e.target.value)}>
|
||||||
|
{condOpts.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sp-cond-editor">
|
<div className="sp-cond-editor">
|
||||||
{/* 4컬럼 row */}
|
{/* 4컬럼 row */}
|
||||||
@@ -731,14 +858,34 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── 노드 생성 헬퍼 ───────────────────────────────────────────────────────────
|
// ─── 노드 생성 헬퍼 ───────────────────────────────────────────────────────────
|
||||||
export const makeNode = (type: string, value: string, signalType: 'buy'|'sell', DEF: DefType): LogicNode => {
|
export type MakeNodeOptions = { composite?: boolean };
|
||||||
|
|
||||||
|
export const makeNode = (
|
||||||
|
type: string,
|
||||||
|
value: string,
|
||||||
|
signalType: 'buy'|'sell',
|
||||||
|
DEF: DefType,
|
||||||
|
options?: MakeNodeOptions,
|
||||||
|
): LogicNode => {
|
||||||
if (type === 'operator') return { id: genId(), type: value as LogicNodeType, children: [] };
|
if (type === 'operator') return { id: genId(), type: value as LogicNodeType, children: [] };
|
||||||
|
if (options?.composite) {
|
||||||
|
return {
|
||||||
|
id: genId(),
|
||||||
|
type: 'CONDITION',
|
||||||
|
condition: makeCompositeCondition(value, signalType, DEF),
|
||||||
|
};
|
||||||
|
}
|
||||||
const def = getDefaultFields(value, signalType, DEF);
|
const def = getDefaultFields(value, signalType, DEF);
|
||||||
|
const baseCondition: ConditionDSL = {
|
||||||
|
indicatorType: value,
|
||||||
|
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
|
||||||
|
leftField: def.l,
|
||||||
|
rightField: def.r,
|
||||||
|
candleRange: 1,
|
||||||
|
period: getDefaultIndicatorPeriod(value, DEF),
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
id: genId(), type: 'CONDITION',
|
id: genId(), type: 'CONDITION',
|
||||||
condition: {
|
condition: initConditionPeriods(value, baseCondition, DEF),
|
||||||
indicatorType: value, conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
|
|
||||||
leftField: def.l, rightField: def.r, candleRange: 1,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Connection, Edge, Node } from '@xyflow/react';
|
import type { Connection, Edge, Node } from '@xyflow/react';
|
||||||
import type { LogicNode } from './strategyTypes';
|
import type { LogicNode, ConditionDSL } from './strategyTypes';
|
||||||
import { nodeToText, type DefType } from './strategyEditorShared';
|
import { nodeToText, type DefType } from './strategyEditorShared';
|
||||||
|
|
||||||
export const START_NODE_ID = '__strategy_start__';
|
export const START_NODE_ID = '__strategy_start__';
|
||||||
@@ -23,6 +23,7 @@ export type StrategyFlowNodeData = {
|
|||||||
signalTab?: 'buy' | 'sell';
|
signalTab?: 'buy' | 'sell';
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
|
onUpdateCondition?: (nodeId: string, condition: ConditionDSL) => void;
|
||||||
onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void;
|
onDropTarget?: (targetId: string, data: { type: string; value: string; label: string }, flowPos: { x: number; y: number }) => void;
|
||||||
onDragOverTarget?: (targetId: string, flowPos: { x: number; y: number }) => void;
|
onDragOverTarget?: (targetId: string, flowPos: { x: number; y: number }) => void;
|
||||||
onDragLeaveTarget?: (targetId: string) => void;
|
onDragLeaveTarget?: (targetId: string) => void;
|
||||||
@@ -423,7 +424,7 @@ function layoutTree(
|
|||||||
positions: Map<string, { x: number; y: number }>,
|
positions: Map<string, { x: number; y: number }>,
|
||||||
def: DefType,
|
def: DefType,
|
||||||
signalTab: 'buy' | 'sell',
|
signalTab: 'buy' | 'sell',
|
||||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||||
dragOverId: string | null,
|
dragOverId: string | null,
|
||||||
): void {
|
): void {
|
||||||
const yBase = 220;
|
const yBase = 220;
|
||||||
@@ -442,6 +443,7 @@ function layoutTree(
|
|||||||
def,
|
def,
|
||||||
signalTab,
|
signalTab,
|
||||||
onDelete: callbacks.onDelete,
|
onDelete: callbacks.onDelete,
|
||||||
|
onUpdateCondition: callbacks.onUpdateCondition,
|
||||||
onDropTarget: callbacks.onDropTarget,
|
onDropTarget: callbacks.onDropTarget,
|
||||||
onDragOverTarget: callbacks.onDragOverTarget,
|
onDragOverTarget: callbacks.onDragOverTarget,
|
||||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||||
@@ -473,7 +475,7 @@ function appendOrphanFlowNodes(
|
|||||||
positions: Map<string, { x: number; y: number }>,
|
positions: Map<string, { x: number; y: number }>,
|
||||||
def: DefType,
|
def: DefType,
|
||||||
signalTab: 'buy' | 'sell',
|
signalTab: 'buy' | 'sell',
|
||||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||||
): void {
|
): void {
|
||||||
let orphanY = 80;
|
let orphanY = 80;
|
||||||
for (const node of orphans) {
|
for (const node of orphans) {
|
||||||
@@ -492,6 +494,7 @@ function appendOrphanFlowNodes(
|
|||||||
def,
|
def,
|
||||||
signalTab,
|
signalTab,
|
||||||
onDelete: callbacks.onDelete,
|
onDelete: callbacks.onDelete,
|
||||||
|
onUpdateCondition: callbacks.onUpdateCondition,
|
||||||
onDropTarget: callbacks.onDropTarget,
|
onDropTarget: callbacks.onDropTarget,
|
||||||
onDragOverTarget: callbacks.onDragOverTarget,
|
onDragOverTarget: callbacks.onDragOverTarget,
|
||||||
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
onDragLeaveTarget: callbacks.onDragLeaveTarget,
|
||||||
@@ -510,7 +513,7 @@ export function logicNodeToFlow(
|
|||||||
def: DefType,
|
def: DefType,
|
||||||
signalTab: 'buy' | 'sell',
|
signalTab: 'buy' | 'sell',
|
||||||
positions: Map<string, { x: number; y: number }>,
|
positions: Map<string, { x: number; y: number }>,
|
||||||
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
callbacks: Pick<StrategyFlowNodeData, 'onDelete' | 'onUpdateCondition' | 'onDropTarget' | 'onDragOverTarget' | 'onDragLeaveTarget'>,
|
||||||
dragOverId: string | null = null,
|
dragOverId: string | null = null,
|
||||||
orphans: LogicNode[] = [],
|
orphans: LogicNode[] = [],
|
||||||
): { nodes: Node[]; edges: Edge[] } {
|
): { nodes: Node[]; edges: Edge[] } {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export interface ConditionDSL {
|
|||||||
conditionType: string;
|
conditionType: string;
|
||||||
targetValue?: number;
|
targetValue?: number;
|
||||||
period?: number;
|
period?: number;
|
||||||
|
/** 복합지표 — 동일 지표 2기간 교차 */
|
||||||
|
composite?: boolean;
|
||||||
|
leftPeriod?: number;
|
||||||
|
rightPeriod?: number;
|
||||||
leftField?: string;
|
leftField?: string;
|
||||||
rightField?: string;
|
rightField?: string;
|
||||||
compareValue?: number;
|
compareValue?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user