차트 설정 수정

This commit is contained in:
Macbook
2026-06-01 01:35:31 +09:00
parent 332019866a
commit f2208aab99
28 changed files with 546 additions and 333 deletions
+13 -5
View File
@@ -63,6 +63,7 @@ import type { PlotDef, HLineStyle } from './indicatorRegistry';
import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils';
import {
applyPaneSeparatorToChartOptions,
cloneChartPaneSeparatorOptions,
resolveChartPaneSeparatorOptions,
type ChartPaneSeparatorOptions,
} from '../types/chartPaneSeparator';
@@ -311,7 +312,8 @@ export class ChartManager {
},
rightPriceScale: { borderColor: t.borderColor },
// pressedMouseMove: TradingChart 에서 커스텀 패닝 처리 (드로잉·클릭과 충돌 방지)
handleScroll: { mouseWheel: true, pressedMouseMove: false, horzTouchDrag: true, vertTouchDrag: false },
// horzTouchDrag: 보조 pane 터치 드래그가 메인 timeScale 과 어긋나는 현상 방지 — TradingChart 커스텀 패닝만 사용
handleScroll: { mouseWheel: true, pressedMouseMove: false, horzTouchDrag: false, vertTouchDrag: false },
handleScale: { axisPressedMouseMove: true, axisDoubleClickReset: true, mouseWheel: true, pinch: true },
});
@@ -595,8 +597,13 @@ export class ChartManager {
}
setPaneSeparatorOptions(opts: ChartPaneSeparatorOptions): void {
this._paneSeparatorOptions = opts;
this.chart.applyOptions(applyPaneSeparatorToChartOptions(opts));
this._paneSeparatorOptions = cloneChartPaneSeparatorOptions(opts);
this.chart.applyOptions(applyPaneSeparatorToChartOptions(this._paneSeparatorOptions));
this._notifyPaneLayout();
}
/** pane 구분선 오버레이 강제 재그리기 (설정 변경·탭 복귀) */
refreshPaneSeparatorOverlay(): void {
this._notifyPaneLayout();
}
@@ -1210,9 +1217,10 @@ export class ChartManager {
): void {
this.liveStrategyMarkers = markers.map(m => {
const buy = m.signal === 'BUY';
const label = buy ? '매수' : '매도';
const text = showPrice
? `실시간 ${buy ? '매수' : '매도'} : ${m.price.toLocaleString()}`
: `실시간 ${buy ? '매수' : '매도'}`;
? `${label} : ${m.price.toLocaleString()}`
: label;
return {
time: m.time,
position: buy ? ('belowBar' as const) : ('aboveBar' as const),
+47 -7
View File
@@ -90,6 +90,18 @@ async function requestOrThrow<T>(path: string, init?: RequestInit): Promise<T> {
return JSON.parse(text) as T;
}
/** 서버에 없는 전략 ID — 삭제·만료 후 반복 404 방지 (태블릿·Safari 네트워크 로그 완화) */
const strategyMissingIds = new Set<number>();
export function isStrategyMissingOnServer(strategyId: number): boolean {
return strategyId > 0 && strategyMissingIds.has(strategyId);
}
function markStrategyMissingFromPath(pathOnly: string): void {
const m = pathOnly.match(/^\/strategies\/(\d+)(?:\/|$)/);
if (m) strategyMissingIds.add(Number(m[1]));
}
async function request<T>(path: string, init?: RequestInit): Promise<T | null> {
try {
const res = await fetch(`${API_BASE}${path}`, {
@@ -100,8 +112,16 @@ async function request<T>(path: string, init?: RequestInit): Promise<T | null> {
if (!res.ok) {
const method = init?.method ?? 'GET';
const guest403 = res.status === 403 && !hasRegisteredUser();
const missing404 = res.status === 404 && method === 'GET';
if (!guest403 && !missing404) {
const pathOnly = path.split('?')[0];
if (res.status === 404 && /^\/strategies\/\d+/.test(pathOnly)) {
markStrategyMissingFromPath(pathOnly);
}
const quiet404 = res.status === 404 && (
method === 'GET'
|| pathOnly.includes('/repair-timeframes')
|| /^\/strategies\/\d+/.test(pathOnly)
);
if (!guest403 && !quiet404) {
console.error(`[backendApi] ${method} ${path}${res.status}`);
}
return null;
@@ -1061,7 +1081,7 @@ export async function loadStrategies(): Promise<StrategyDto[]> {
/** 단일 전략 로드 */
export async function loadStrategy(id: number): Promise<StrategyDto | null> {
if (!hasRegisteredUser()) return null;
if (!hasRegisteredUser() || id <= 0 || strategyMissingIds.has(id)) return null;
return request<StrategyDto>(`/strategies/${id}`);
}
@@ -1401,10 +1421,30 @@ export async function loadStrategyTimeframes(strategyId: number): Promise<string
return list?.length ? list : ['1m'];
}
/** DB DSL TIMEFRAME 래핑 보정 (3분봉 전략이 1m으로 평가되는 레거시 수정) */
export async function repairStrategyTimeframes(strategyId: number): Promise<void> {
if (!hasRegisteredUser()) return;
await request(`/strategies/${strategyId}/repair-timeframes`, { method: 'POST' });
const repairSkipIds = new Set<number>();
const repairInflight = new Set<number>();
/** DB DSL TIMEFRAME 래핑 보정 — 전략 없으면 호출 안 함(404 방지) */
export async function repairStrategyTimeframes(strategyId: number): Promise<boolean> {
if (!hasRegisteredUser() || strategyId <= 0 || strategyMissingIds.has(strategyId)) return false;
if (repairSkipIds.has(strategyId) || repairInflight.has(strategyId)) return false;
repairInflight.add(strategyId);
try {
const exists = await loadStrategy(strategyId);
if (!exists) {
repairSkipIds.add(strategyId);
return false;
}
const ok = await request<StrategyDto>(
`/strategies/${strategyId}/repair-timeframes`,
{ method: 'POST' },
);
if (!ok) repairSkipIds.add(strategyId);
return ok != null;
} finally {
repairInflight.delete(strategyId);
}
}
/** 차트 실시간 파이프라인 pin (STOMP warm-up) */
+2
View File
@@ -35,6 +35,8 @@ export function formatPlotColor(hex6: string, alphaPercent: number): string {
}
export function plotColorCss(value: string): string {
const v = value.trim();
if (/^rgba?\(/i.test(v) || /^hsla?\(/i.test(v)) return v;
const { hex6, alpha } = parsePlotColor(value);
if (alpha >= 100) return hex6;
const r = parseInt(hex6.slice(1, 3), 16);
+3 -6
View File
@@ -7,12 +7,9 @@ export async function pinStrategyEvaluationTimeframes(
market: string,
strategyId: number,
): Promise<string[]> {
await syncStrategyTimeframesFromLayoutIfNeeded(strategyId);
try {
await repairStrategyTimeframes(strategyId);
} catch {
/* 서버 미배포·권한 등 — timeframes 조회만 진행 */
}
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(strategyId);
if (!synced) return ['1m'];
await repairStrategyTimeframes(strategyId);
const raw = await loadStrategyTimeframes(strategyId);
const timeframes = [...new Set(raw.map(tf => normalizeStartCandleType(tf)))];
if (timeframes.length === 0) timeframes.push('1m');
+15 -10
View File
@@ -1,4 +1,11 @@
import { loadStrategy, loadStrategyTimeframes, repairStrategyTimeframes, saveStrategy, type StrategyDto } from './backendApi';
import {
isStrategyMissingOnServer,
loadStrategy,
loadStrategyTimeframes,
repairStrategyTimeframes,
saveStrategy,
type StrategyDto,
} from './backendApi';
import {
alignBuySellStartCandleTypesForSave,
collectDslBranches,
@@ -143,9 +150,10 @@ function buildEditorStateFromStrategy(
*/
export async function syncStrategyTimeframesFromLayoutIfNeeded(
strategyId: number,
strategy?: StrategyDto | null,
_strategy?: StrategyDto | null,
): Promise<boolean> {
const strat = (await loadStrategy(strategyId)) ?? strategy ?? null;
if (isStrategyMissingOnServer(strategyId)) return false;
const strat = await loadStrategy(strategyId);
if (!strat) return false;
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat);
@@ -162,13 +170,10 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
const layout = strat.flowLayout;
if (!layout) {
try {
await repairStrategyTimeframes(strategyId);
const repaired = await loadStrategyTimeframes(strategyId);
return evaluationTimeframesMatch(uiTfs, repaired);
} catch {
return false;
}
const repaired = await repairStrategyTimeframes(strategyId);
if (!repaired) return false;
const tfs = await loadStrategyTimeframes(strategyId);
return evaluationTimeframesMatch(uiTfs, tfs);
}
const buyState = buildEditorStateFromStrategy(strat, layout, 'buy');
+25 -8
View File
@@ -15,11 +15,11 @@ interface Listener {
handler: RawHandler;
}
const INITIAL_DELAY = 80;
const RECONNECT_BASE = 2000;
const RECONNECT_MAX = 30_000;
const INITIAL_DELAY = 350;
const RECONNECT_BASE = 3000;
const RECONNECT_MAX = 45_000;
const PING_INTERVAL = 20_000;
const DISCONNECT_IDLE_MS = 5_000;
const DISCONNECT_IDLE_MS = 200;
const refCounts = new Map<UpbitWsChannel, Map<string, number>>();
const listeners = new Set<Listener>();
@@ -33,6 +33,7 @@ let pingTimer: ReturnType<typeof setInterval> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let retryCount = 0;
let closingIntentionally = false;
let wsFailureLogged = false;
function getWsUrl(): string {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
@@ -175,6 +176,8 @@ function openSocket(): void {
if (listenerCount() === 0) return;
teardownSocket();
if (!mayOpenSocket()) return;
try {
const socket = new WebSocket(getWsUrl());
ws = socket;
@@ -182,6 +185,7 @@ function openSocket(): void {
socket.onopen = () => {
retryCount = 0;
wsFailureLogged = false;
notifyConnection(true);
sendSubscribe();
clearPing();
@@ -197,7 +201,13 @@ function openSocket(): void {
};
socket.onerror = () => {
console.warn('[upbitWsBroker] socket error');
if (!wsFailureLogged) {
wsFailureLogged = true;
console.warn(
'[upbitWsBroker] WebSocket 연결 실패 — 시세는 REST 폴링으로 대체됩니다. '
+ '서버 nginx에 /upbit-ws 프록시가 있는지 확인하세요.',
);
}
};
socket.onclose = () => {
@@ -207,7 +217,10 @@ function openSocket(): void {
if (!closingIntentionally && listenerCount() > 0) scheduleReconnect();
};
} catch (err) {
console.warn('[upbitWsBroker] connect failed', err);
if (!wsFailureLogged) {
wsFailureLogged = true;
console.warn('[upbitWsBroker] connect failed', err);
}
scheduleReconnect();
}
}
@@ -274,8 +287,12 @@ export function subscribeUpbitWs(
return () => {
listeners.delete(entry);
normalized.forEach(code => bumpRef(channel, code, -1));
if (listenerCount() === 0) scheduleDisconnect();
else if (ws?.readyState === WebSocket.OPEN) sendSubscribe();
if (listenerCount() === 0) {
cancelPendingConnect();
scheduleDisconnect();
} else if (ws?.readyState === WebSocket.OPEN) {
sendSubscribe();
}
};
}
@@ -95,7 +95,6 @@ export async function removeVirtualTarget(market: string): Promise<VirtualTarget
const targets = loadVirtualTargets();
const item = targets.find(t => t.market === market);
if (!item) return targets;
if (item.pinned) return targets;
const session = loadVirtualSession();
const next = targets.filter(t => t.market !== market);
+1 -1
View File
@@ -13,7 +13,7 @@ export interface VirtualTargetItem {
strategyId: number | null;
koreanName?: string;
englishName?: string;
/** 투자대상 고정 — ON 이면 목록·추세검색에서 삭제 불가 (gc_live_strategy_settings.is_pinned) */
/** 투자대상 고정 — 추세검색 자동삭제 등에서 보호 (수동 삭제는 가능, gc_live_strategy_settings.is_pinned) */
pinned?: boolean;
}