전략 시간봉 오류 수정

This commit is contained in:
Macbook
2026-05-28 00:36:49 +09:00
parent 2713b2951d
commit 4f6694b206
16 changed files with 285 additions and 127 deletions
+9 -2
View File
@@ -260,7 +260,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
cfg = { ...fromConfig, id: newIndId(), type: def.type };
} else {
const params = getParams(type, def.defaultParams);
const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines);
const { plots, hlines, cloudColors, bandBackground } = getVisualConfig(type, def.plots, def.hlines);
cfg = {
id: newIndId(),
type: def.type,
@@ -268,6 +268,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
plots,
hlines,
...(cloudColors ? { cloudColors } : {}),
...(bandBackground ? { bandBackground } : {}),
};
}
if (type === 'SMA') {
@@ -566,7 +567,13 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
// 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영
saveParams(updated.type, updated.params);
// 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용
saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors);
saveVisual(
updated.type,
updated.plots,
updated.hlines,
updated.cloudColors,
updated.bandBackground,
);
}, [saveParams, saveVisual]);
const handleToggleHidden = useCallback((id: string) => {
@@ -191,7 +191,7 @@ export const PlotSettingsRow: React.FC<{
isHistogram && indicatorType === 'MACD' ? (
<div className="ism-hist-colors">
<div className="ism-hist-color-field">
<span className="ism-style-label"></span>
<span className="ism-style-label">0 </span>
<ColorInput
value={resolveHistogramUpColor(plot)}
disabled={!enabled}
@@ -199,7 +199,7 @@ export const PlotSettingsRow: React.FC<{
/>
</div>
<div className="ism-hist-color-field">
<span className="ism-style-label"></span>
<span className="ism-style-label">0 </span>
<ColorInput
value={resolveHistogramDownColor(plot)}
disabled={!enabled}
@@ -213,7 +213,7 @@ const IndicatorSettingsStyleSection: React.FC<IndicatorSettingsStyleSectionProps
</div>
<div className="ism-style-row">
<div className="ism-plot-title-cell">
<span className="ism-plot-title ism-plot-title--sub"> </span>
<span className="ism-plot-title ism-plot-title--sub">0 ()</span>
</div>
<div className="ism-style-field ism-style-field--wide">
<span className="ism-style-label"></span>
@@ -225,7 +225,7 @@ const IndicatorSettingsStyleSection: React.FC<IndicatorSettingsStyleSectionProps
</div>
<div className="ism-style-row">
<div className="ism-plot-title-cell">
<span className="ism-plot-title ism-plot-title--sub"> </span>
<span className="ism-plot-title ism-plot-title--sub">0 ()</span>
</div>
<div className="ism-style-field ism-style-field--wide">
<span className="ism-style-label"></span>
+66 -92
View File
@@ -69,11 +69,6 @@ function getDomSubIndicatorLayouts(
return subs;
}
/** 보조지표 1개 = 1 화면 슬롯 (병합 시 여러 item 이 한 슬롯) */
interface VisualSlot {
items: PaneItem[];
}
interface PaneCapture {
dataUrl: string;
cssWidth: number;
@@ -220,80 +215,71 @@ function makeLabel(config: IndicatorConfig): string {
return nums.length ? `${name} ${nums.join(', ')}` : name;
}
function buildVisualSlots(items: PaneItem[], indicators: IndicatorConfig[]): VisualSlot[] {
const slots: VisualSlot[] = [];
const hostSlotIdx = new Map<string, number>();
for (const item of items) {
const ind = indicators.find(i => i.id === item.id);
if (ind?.mergedWith) {
const hostId = getPaneHostId(item.id, indicators);
const idx = hostSlotIdx.get(hostId);
if (idx !== undefined) slots[idx].items.push(item);
continue;
}
const idx = slots.length;
slots.push({ items: [item] });
hostSlotIdx.set(item.id, idx);
hostSlotIdx.set(getPaneHostId(item.id, indicators), idx);
}
return slots;
/** paneIndex ↔ LWC pane DOM 위치 (배열 순서·orphan pane 과 무관) */
function layoutForPaneIndex(
paneIndex: number,
layouts: Array<{ paneIndex: number; topY: number; height: number }>,
): { topY: number; height: number } | null {
if (paneIndex < 2) return null;
const lay = layouts.find(l => l.paneIndex === paneIndex && l.height > MIN_SUB_PANE_HEIGHT);
return lay ? { topY: lay.topY, height: lay.height } : null;
}
/** 각 슬롯(호스트 paneIndex) ↔ LWC pane 레이아웃 직접 매칭 — 배열 순서·orphan pane 무관 */
function assignLayoutsByPaneIndex(
slots: VisualSlot[],
function applyPaneLayoutToItem(
item: PaneItem,
layout: { topY: number; height: number },
overwrite = false,
): void {
if (!overwrite && item.layoutTopY != null) return;
item.layoutTopY = layout.topY;
item.layoutHeight = layout.height;
}
/** paneIndex 기준으로만 좌표 보강 (지표 배열 순서와 화면 pane 순서가 달라도 안전) */
function fillMissingLayoutsByPaneIndex(
items: PaneItem[],
layouts: Array<{ paneIndex: number; topY: number; height: number }>,
): void {
const byPane = new Map(layouts.map(l => [l.paneIndex, l]));
for (const slot of slots) {
const host = slot.items[0];
if (!host || host.paneIndex < 2) continue;
const lay = byPane.get(host.paneIndex);
if (!lay || lay.height < MIN_SUB_PANE_HEIGHT) continue;
for (const item of slot.items) {
if (item.layoutTopY != null) continue;
item.layoutTopY = lay.topY;
item.layoutHeight = lay.height;
}
for (const item of items) {
if (item.layoutTopY != null || item.paneIndex < 2) continue;
const lay = layoutForPaneIndex(item.paneIndex, layouts);
if (lay) applyPaneLayoutToItem(item, lay);
}
}
/** 화면 순서(위→아래)의 활성 sub-pane ↔ 슬롯 순서 — orphan pane index 와 분리 */
function assignLayoutsBySubPaneOrder(
slots: VisualSlot[],
subLayouts: Array<{ paneIndex: number; topY: number; height: number }>,
/** DOM 행 중 paneIndex 레이아웃 topY 와 가장 가까운 행 선택 */
function fillMissingLayoutsFromDom(
items: PaneItem[],
containerEl: HTMLElement,
paneLayouts: Array<{ paneIndex: number; topY: number; height: number }>,
): void {
for (let i = 0; i < slots.length && i < subLayouts.length; i++) {
const lay = subLayouts[i];
for (const item of slots[i].items) {
if (item.layoutTopY != null) continue;
item.layoutTopY = lay.topY;
item.layoutHeight = lay.height;
}
}
}
const missing = items.filter(i => i.layoutTopY == null && i.paneIndex >= 2);
if (missing.length === 0) return;
/** paneIndex·orphan pane 으로 어긋난 좌표를 슬롯 순서 기준 sub-pane 위치로 보정 */
function reconcileLayoutsWithSubPanes(
slots: VisualSlot[],
subLayouts: Array<{ paneIndex: number; topY: number; height: number }>,
): void {
const TOL = 24;
for (let i = 0; i < slots.length && i < subLayouts.length; i++) {
const expected = subLayouts[i];
for (const item of slots[i].items) {
const cur = item.layoutTopY;
const curH = item.layoutHeight;
if (
cur == null
|| Math.abs(cur - expected.topY) > TOL
|| (curH != null && Math.abs(curH - expected.height) > TOL)
) {
item.layoutTopY = expected.topY;
item.layoutHeight = expected.height;
const domSubs = getDomSubIndicatorLayouts(containerEl, missing.length);
if (domSubs.length === 0) return;
const usedDom = new Set<number>();
const sorted = [...missing].sort((a, b) => a.paneIndex - b.paneIndex);
for (const item of sorted) {
const ref = layoutForPaneIndex(item.paneIndex, paneLayouts);
let bestIdx = -1;
let bestDist = Infinity;
for (let i = 0; i < domSubs.length; i++) {
if (usedDom.has(i)) continue;
const dist = ref
? Math.abs(domSubs[i].topY - ref.topY)
: domSubs[i].topY;
if (dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
}
if (bestIdx < 0) continue;
if (ref && bestDist > 96) continue;
usedDom.add(bestIdx);
applyPaneLayoutToItem(item, domSubs[bestIdx]);
}
}
@@ -333,36 +319,24 @@ function buildPaneItems(
for (const item of items) {
const lay = manager.getIndicatorPaneScreenLayout(item.id);
if (lay) {
item.layoutTopY = lay.topY;
item.layoutHeight = lay.height;
}
if (lay) applyPaneLayoutToItem(item, lay, true);
}
const slots = buildVisualSlots(items, indicators);
const subLayouts = manager.getIndicatorSubPaneLayouts();
assignLayoutsBySubPaneOrder(slots, subLayouts);
assignLayoutsByPaneIndex(slots, manager.getPaneLayouts());
if (subLayouts.length > 0) {
reconcileLayoutsWithSubPanes(slots, subLayouts);
}
const paneLayouts = manager.getPaneLayouts();
fillMissingLayoutsByPaneIndex(items, paneLayouts);
fillMissingLayoutsByPaneIndex(items, manager.getIndicatorSubPaneLayouts());
if (containerEl?.isConnected) {
const missing = items.filter(i => i.layoutTopY == null);
if (missing.length > 0) {
const domSubs = getDomSubIndicatorLayouts(containerEl, slots.length);
for (let i = 0; i < slots.length && i < domSubs.length; i++) {
if (slots[i].items.every(it => it.layoutTopY != null)) continue;
const dom = domSubs[i];
for (const item of slots[i].items) {
if (item.layoutTopY != null) continue;
item.layoutTopY = dom.topY;
item.layoutHeight = dom.height;
}
}
}
fillMissingLayoutsFromDom(items, containerEl, paneLayouts);
}
items.sort((a, b) => {
const ay = a.layoutTopY ?? 1e9;
const by = b.layoutTopY ?? 1e9;
if (ay !== by) return ay - by;
return a.paneIndex - b.paneIndex;
});
return items;
}
+37 -5
View File
@@ -23,7 +23,9 @@ import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
import {
decodeConditionForEditor,
alignBuySellStartCandleTypesForSave,
encodeConditionForSave,
syncBuySellPrimaryStartCandleTypes,
mergeStartMetaForLoad,
addExtraStartSection,
hasMultipleStartSections,
@@ -33,6 +35,7 @@ import {
type EditorConditionState,
} from '../utils/strategyConditionSerde';
import {
START_NODE_ID,
defaultStartMeta,
type StartCombineOp,
} from '../utils/strategyStartNodes';
@@ -393,9 +396,36 @@ export default function StrategyEditorPage({ theme }: Props) {
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
if (startId === START_NODE_ID) {
const synced = syncBuySellPrimaryStartCandleTypes(buyEditorState, sellEditorState, candleTypes);
setBuyCondition(synced.buy.root);
setBuyLayout(prev => ({
...prev,
startMeta: synced.buy.startMeta,
extraStartIds: synced.buy.extraStartIds,
extraRoots: synced.buy.extraRoots,
startCombineOp: normalizeStartCombineOp(synced.buy.startCombineOp),
}));
setSellCondition(synced.sell.root);
setSellLayout(prev => ({
...prev,
startMeta: synced.sell.startMeta,
extraStartIds: synced.sell.extraStartIds,
extraRoots: synced.sell.extraRoots,
startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp),
}));
schedulePersistFlowLayout(layoutStrategyKey);
return;
}
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
}, [signalTab, buyEditorState, sellEditorState, handleEditorStateChange]);
}, [
buyEditorState,
sellEditorState,
handleEditorStateChange,
layoutStrategyKey,
schedulePersistFlowLayout,
]);
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
handleEditorStateChange(updateStartCombineOp(currentEditorState, op));
@@ -559,8 +589,9 @@ export default function StrategyEditorPage({ theme }: Props) {
}
setSaveNameError(false);
if (editorMode === 'graph') layoutFlushRef.current?.();
const encodedBuy = encodeConditionForSave(buyEditorState);
const encodedSell = encodeConditionForSave(sellEditorState);
const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState);
const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
setIsSaving(true);
try {
@@ -642,8 +673,9 @@ export default function StrategyEditorPage({ theme }: Props) {
}, [resetFlowLayout, bumpLayoutSeed, signalTab]);
const handleExport = useCallback(() => {
const encodedBuy = encodeConditionForSave(buyEditorState);
const encodedSell = encodeConditionForSave(sellEditorState);
const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState);
const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
if (!encodedBuy && !encodedSell) {
showSnack('내보낼 조건이 없습니다', false);
return;
+1 -1
View File
@@ -1430,7 +1430,7 @@ function sortedParamKey(inds: IndicatorConfig[]): string {
*/
function styleKey(inds: IndicatorConfig[]): string {
return inds.map(i =>
`${i.id}|${i.hidden ? '1' : '0'}|${i.lastValueVisible === false ? '0' : '1'}|${(i.plots ?? []).map(p => `${p.color ?? ''}:${p.lineWidth ?? 1}:${p.lineStyle ?? 'solid'}:${p.histogramUpColor ?? ''}:${p.histogramDownColor ?? ''}`).join(',')}|${JSON.stringify(i.plotVisibility ?? {})}|${JSON.stringify((i.hlines ?? []).map(h => `${h.price}:${h.color}:${h.visible ?? true}:${h.lineStyle ?? 'dashed'}:${h.lineWidth ?? 1}`))}|${JSON.stringify(i.cloudColors ?? {})}`
`${i.id}|${i.hidden ? '1' : '0'}|${i.lastValueVisible === false ? '0' : '1'}|${(i.plots ?? []).map(p => `${p.color ?? ''}:${p.lineWidth ?? 1}:${p.lineStyle ?? 'solid'}:${p.histogramUpColor ?? ''}:${p.histogramDownColor ?? ''}`).join(',')}|${JSON.stringify(i.plotVisibility ?? {})}|${JSON.stringify((i.hlines ?? []).map(h => `${h.price}:${h.color}:${h.visible ?? true}:${h.lineStyle ?? 'dashed'}:${h.lineWidth ?? 1}`))}|${JSON.stringify(i.cloudColors ?? {})}|${JSON.stringify(i.hlinesBackground ?? {})}|${JSON.stringify(i.bandBackground ?? {})}`
).join(';');
}