매매 시그널 알림 화면 수정
This commit is contained in:
@@ -25,7 +25,14 @@ import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
|
||||
import { resolveBbBandBackground } from './bollingerConfig';
|
||||
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
|
||||
import { formatChartAxisPrice } from './dataGenerator';
|
||||
import { formatLwcTime, formatLwcTickMark, DEFAULT_DISPLAY_TIMEZONE } from './timezone';
|
||||
import {
|
||||
DEFAULT_CHART_TIME_FORMAT,
|
||||
formatUnixWithChartPattern,
|
||||
formatLwcTimeWithPattern,
|
||||
formatLwcTickMarkWithPattern,
|
||||
normalizeChartTimeFormat,
|
||||
} from './chartTimeFormat';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from './timezone';
|
||||
import { sortIndicatorsForPaneLoad } from './indicatorPaneMerge';
|
||||
import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry';
|
||||
import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin';
|
||||
@@ -203,6 +210,7 @@ export class ChartManager {
|
||||
private currentChartType: ChartType = 'candlestick';
|
||||
private displayTimezone = DEFAULT_DISPLAY_TIMEZONE;
|
||||
private displayTimeframe: Timeframe = '1D';
|
||||
private chartTimeFormat = DEFAULT_CHART_TIME_FORMAT;
|
||||
private mainMarkersPlugin: ISeriesMarkersPluginApi<Time> | null = null;
|
||||
private patternMarkers: Array<{ id: string; markers: MarkerData[] }> = [];
|
||||
/** 백테스팅 매수/매도 시그널 마커 */
|
||||
@@ -461,29 +469,37 @@ export class ChartManager {
|
||||
|
||||
// ─── Display timezone ───────────────────────────────────────────────────
|
||||
private _tickMarkFormatter(): TickMarkFormatter {
|
||||
const tf = this.displayTimeframe;
|
||||
const tz = this.displayTimezone;
|
||||
const fmt = this.chartTimeFormat;
|
||||
return (time, tickMarkType) =>
|
||||
formatLwcTickMark(
|
||||
time as Time,
|
||||
tickMarkType,
|
||||
this.displayTimeframe,
|
||||
this.displayTimezone,
|
||||
);
|
||||
formatLwcTickMarkWithPattern(time as Time, tickMarkType, tf, tz, fmt);
|
||||
}
|
||||
|
||||
private _applyDisplayTimezoneOptions(): void {
|
||||
const tf = this.displayTimeframe;
|
||||
const tz = this.displayTimezone;
|
||||
const fmt = this.chartTimeFormat;
|
||||
const timeFormatter = (time: Time) =>
|
||||
formatLwcTime(time as number | { year: number; month: number; day: number }, tf, tz);
|
||||
formatLwcTimeWithPattern(
|
||||
time as number | { year: number; month: number; day: number },
|
||||
tf,
|
||||
tz,
|
||||
fmt,
|
||||
);
|
||||
this.chart.applyOptions({
|
||||
localization: { timeFormatter, priceFormatter: formatChartAxisPrice },
|
||||
timeScale: { tickMarkFormatter: this._tickMarkFormatter() },
|
||||
});
|
||||
this._notifyPaneLayout();
|
||||
}
|
||||
|
||||
setDisplayTimezone(tz: string, timeframe?: Timeframe): void {
|
||||
setDisplayTimezone(tz: string, timeframe?: Timeframe, chartTimeFormat?: string): void {
|
||||
this.displayTimezone = tz || DEFAULT_DISPLAY_TIMEZONE;
|
||||
if (timeframe) this.displayTimeframe = timeframe;
|
||||
if (chartTimeFormat != null) {
|
||||
this.chartTimeFormat = normalizeChartTimeFormat(chartTimeFormat);
|
||||
}
|
||||
this._applyDisplayTimezoneOptions();
|
||||
}
|
||||
|
||||
@@ -805,12 +821,27 @@ export class ChartManager {
|
||||
/** 파라미터 변경된 지표만 제거 후 재추가 (전체 보조지표 재로드 회피) */
|
||||
async refreshIndicators(configs: IndicatorConfig[]): Promise<void> {
|
||||
if (configs.length === 0) return;
|
||||
this.cancelPendingIndicatorUpdates();
|
||||
const loadGen = ++this._dataGeneration;
|
||||
|
||||
let any = false;
|
||||
for (const c of configs) {
|
||||
if (this._detachIndicatorEntry(c.id)) any = true;
|
||||
}
|
||||
if (any) this._trimTrailingEmptySubPanes();
|
||||
await this.addIndicatorsBatch(configs);
|
||||
if (this._indicatorLoadStale(loadGen)) return;
|
||||
|
||||
for (const config of configs) {
|
||||
if (this._indicatorLoadStale(loadGen)) break;
|
||||
await this.addIndicator(config, { skipLayout: true });
|
||||
}
|
||||
if (this._indicatorLoadStale(loadGen)) return;
|
||||
|
||||
this._removeOrphanSubPanes();
|
||||
if (this._activeIndicatorPaneIndices().size > 0) {
|
||||
this.resetPaneHeights(this._lastLayoutAvailableHeight);
|
||||
this._notifyPaneLayout();
|
||||
}
|
||||
}
|
||||
|
||||
/** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
|
||||
@@ -2437,6 +2468,76 @@ export class ChartManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 캔들 pane(0) 플롯 영역 너비 (우측 가격축 제외) */
|
||||
private _candlePlotWidth(): number {
|
||||
try {
|
||||
const ps = this.mainSeries?.priceScale() ?? this.chart.priceScale('right', 0);
|
||||
const scaleW = ps.width();
|
||||
return Math.max(40, this.container.clientWidth - scaleW);
|
||||
} catch {
|
||||
return Math.max(40, this.container.clientWidth - 56);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캔들 pane 하단 시간축 오버레이 — 거래량·보조지표가 있을 때 캔들 영역 바로 아래에도 시각 표시.
|
||||
*/
|
||||
getCandlePaneTimeAxisOverlay(): {
|
||||
topY: number;
|
||||
height: number;
|
||||
plotWidth: number;
|
||||
labels: Array<{ x: number; text: string }>;
|
||||
} | null {
|
||||
if (!this.mainSeries || this.rawBars.length < 2) return null;
|
||||
|
||||
const layouts = this.getPaneLayouts();
|
||||
const hasLowerPane = layouts.some(l => l.paneIndex > 0 && l.height > 8);
|
||||
if (!hasLowerPane) return null;
|
||||
|
||||
const main = layouts.find(l => l.paneIndex === 0);
|
||||
if (!main || main.height < 48) return null;
|
||||
|
||||
const AXIS_H = 22;
|
||||
const ts = this.chart.timeScale();
|
||||
const lr = ts.getVisibleLogicalRange();
|
||||
if (!lr) return null;
|
||||
|
||||
const plotWidth = this._candlePlotWidth();
|
||||
const from = Math.max(0, Math.ceil(lr.from));
|
||||
const to = Math.min(this.rawBars.length - 1, Math.floor(lr.to));
|
||||
if (to <= from) return null;
|
||||
|
||||
const span = to - from;
|
||||
const targetCount = Math.max(4, Math.min(12, Math.floor(plotWidth / 76)));
|
||||
const step = Math.max(1, Math.ceil(span / targetCount));
|
||||
|
||||
const labels: Array<{ x: number; text: string }> = [];
|
||||
const fmt = this.chartTimeFormat;
|
||||
const tz = this.displayTimezone;
|
||||
|
||||
for (let i = from; i <= to; i += step) {
|
||||
const bar = this.rawBars[i];
|
||||
if (!bar?.time) continue;
|
||||
const coord = ts.timeToCoordinate(bar.time as Time);
|
||||
if (coord == null) continue;
|
||||
const x = Number(coord);
|
||||
if (!Number.isFinite(x) || x < 12 || x > plotWidth - 12) continue;
|
||||
labels.push({
|
||||
x,
|
||||
text: formatUnixWithChartPattern(bar.time, fmt, tz),
|
||||
});
|
||||
}
|
||||
|
||||
if (labels.length === 0) return null;
|
||||
|
||||
return {
|
||||
topY: main.topY + main.height - AXIS_H,
|
||||
height: AXIS_H,
|
||||
plotWidth,
|
||||
labels,
|
||||
};
|
||||
}
|
||||
|
||||
/** 하단 시간축 영역 클릭 여부 */
|
||||
isOnTimeAxis(chartY: number): boolean {
|
||||
try {
|
||||
|
||||
@@ -100,7 +100,7 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
|
||||
P('메뉴-백테스팅-분석 차트', 'analysis'),
|
||||
|
||||
// ── 상단 메뉴바 기타 ──
|
||||
P('메뉴-알림 목록', 'notifications', '시그널목록'),
|
||||
P('메뉴-알림목록', 'notifications', '알림목록'),
|
||||
P('메뉴-테마 전환', 'theme', '다크', '라이트'),
|
||||
P('메뉴-로그인', 'login', 'auth'),
|
||||
];
|
||||
|
||||
@@ -444,6 +444,8 @@ export interface AppSettingsDto {
|
||||
fcmPushEnabled?: boolean;
|
||||
/** 차트·UI 표시 시간대 (IANA, 예: Asia/Seoul) */
|
||||
displayTimezone?: string;
|
||||
/** 차트 하단 시간축 포맷 (예: yyyy-MM-dd HH:mm) */
|
||||
chartTimeFormat?: string;
|
||||
/** 추세검색 기본 설정 */
|
||||
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
|
||||
/** UI 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 차트 하단 시간축·크로스헤어 시간 표시 포맷 (앱 설정 chartTimeFormat).
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TickMarkType, type Time } from 'lightweight-charts';
|
||||
import type { Timeframe } from '../types';
|
||||
import { getDisplayTimezone, normalizeTimezone } from './timezone';
|
||||
|
||||
export const DEFAULT_CHART_TIME_FORMAT = 'MM-dd HH:mm';
|
||||
|
||||
export interface ChartTimeFormatPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 통상적인 차트 시간축 프리셋 */
|
||||
export const CHART_TIME_FORMAT_PRESETS: ChartTimeFormatPreset[] = [
|
||||
{ id: 'yyyy-MM-dd HH:mm:ss', label: 'yyyy-MM-dd HH:mm:ss' },
|
||||
{ id: 'yyyy-MM-dd HH:mm', label: 'yyyy-MM-dd HH:mm' },
|
||||
{ id: 'yyyy-MM-dd', label: 'yyyy-MM-dd' },
|
||||
{ id: 'MM-dd HH:mm:ss', label: 'MM-dd HH:mm:ss' },
|
||||
{ id: 'MM-dd HH:mm', label: 'MM-dd HH:mm' },
|
||||
{ id: 'dd/MM HH:mm', label: 'dd/MM HH:mm' },
|
||||
{ id: 'dd-MM HH:mm', label: 'dd-MM HH:mm' },
|
||||
{ id: 'HH:mm:ss', label: 'HH:mm:ss' },
|
||||
{ id: 'HH:mm', label: 'HH:mm' },
|
||||
];
|
||||
|
||||
const PRESET_IDS = new Set(CHART_TIME_FORMAT_PRESETS.map(p => p.id));
|
||||
|
||||
let _format = DEFAULT_CHART_TIME_FORMAT;
|
||||
const _listeners = new Set<() => void>();
|
||||
|
||||
export function getChartTimeFormat(): string {
|
||||
return _format;
|
||||
}
|
||||
|
||||
export function setChartTimeFormat(format: string): void {
|
||||
const next = normalizeChartTimeFormat(format);
|
||||
if (_format === next) return;
|
||||
_format = next;
|
||||
_listeners.forEach(l => l());
|
||||
}
|
||||
|
||||
export function subscribeChartTimeFormat(cb: () => void): () => void {
|
||||
_listeners.add(cb);
|
||||
return () => _listeners.delete(cb);
|
||||
}
|
||||
|
||||
export function useChartTimeFormat(): string {
|
||||
return useSyncExternalStore(
|
||||
subscribeChartTimeFormat,
|
||||
getChartTimeFormat,
|
||||
() => DEFAULT_CHART_TIME_FORMAT,
|
||||
);
|
||||
}
|
||||
|
||||
/** 허용 토큰: yyyy yy MM dd HH mm ss */
|
||||
export function normalizeChartTimeFormat(format: string): string {
|
||||
const t = (format || '').trim();
|
||||
if (!t) return DEFAULT_CHART_TIME_FORMAT;
|
||||
if (!/[yMdHms]/.test(t)) return DEFAULT_CHART_TIME_FORMAT;
|
||||
return t.slice(0, 64);
|
||||
}
|
||||
|
||||
export function isPresetChartTimeFormat(format: string): boolean {
|
||||
return PRESET_IDS.has(normalizeChartTimeFormat(format));
|
||||
}
|
||||
|
||||
interface ZonedParts {
|
||||
yyyy: string;
|
||||
yy: string;
|
||||
MM: string;
|
||||
dd: string;
|
||||
HH: string;
|
||||
mm: string;
|
||||
ss: string;
|
||||
}
|
||||
|
||||
function getZonedParts(unixSec: number, tz: string): ZonedParts {
|
||||
const d = new Date(unixSec * 1000);
|
||||
const zone = normalizeTimezone(tz);
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(d);
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of parts) {
|
||||
if (p.type !== 'literal') map[p.type] = p.value;
|
||||
}
|
||||
const year = map.year ?? '';
|
||||
return {
|
||||
yyyy: year,
|
||||
yy: year.slice(-2),
|
||||
MM: map.month ?? '',
|
||||
dd: map.day ?? '',
|
||||
HH: map.hour ?? '',
|
||||
mm: map.minute ?? '',
|
||||
ss: map.second ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
const TOKEN_ORDER = ['yyyy', 'yy', 'MM', 'dd', 'HH', 'mm', 'ss'] as const;
|
||||
|
||||
export function applyChartTimePattern(pattern: string, parts: ZonedParts): string {
|
||||
let out = normalizeChartTimeFormat(pattern);
|
||||
for (const tok of TOKEN_ORDER) {
|
||||
out = out.split(tok).join(parts[tok]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function patternHasDate(pattern: string): boolean {
|
||||
return /yyyy|yy|MM|dd/.test(pattern);
|
||||
}
|
||||
|
||||
function patternHasTime(pattern: string): boolean {
|
||||
return /HH|mm|ss/.test(pattern);
|
||||
}
|
||||
|
||||
/** 패턴에서 날짜·시간 부분 분리 (눈금 타입별 표시용) */
|
||||
export function splitChartTimePattern(pattern: string): { date: string; time: string } {
|
||||
const p = normalizeChartTimeFormat(pattern);
|
||||
if (!patternHasTime(p)) return { date: p, time: '' };
|
||||
if (!patternHasDate(p)) return { date: '', time: p };
|
||||
|
||||
const timeIdx = p.search(/HH|mm|ss/);
|
||||
if (timeIdx < 0) return { date: p, time: '' };
|
||||
|
||||
return {
|
||||
date: p.slice(0, timeIdx).trim(),
|
||||
time: p.slice(timeIdx).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function isDailyTimeframe(tf?: string): boolean {
|
||||
return tf === '1D' || tf === '1W' || tf === '1M';
|
||||
}
|
||||
|
||||
/** unix 초 → 사용자 지정 포맷 */
|
||||
export function formatUnixWithChartPattern(
|
||||
unixSec: number,
|
||||
pattern?: string,
|
||||
tz?: string,
|
||||
): string {
|
||||
if (!unixSec) return '';
|
||||
const zone = normalizeTimezone(tz ?? getDisplayTimezone());
|
||||
const parts = getZonedParts(unixSec, zone);
|
||||
return applyChartTimePattern(pattern ?? _format, parts);
|
||||
}
|
||||
|
||||
/** LWC 크로스헤어·timeFormatter */
|
||||
export function formatLwcTimeWithPattern(
|
||||
time: number | { year: number; month: number; day: number },
|
||||
timeframe?: Timeframe,
|
||||
tz?: string,
|
||||
pattern?: string,
|
||||
): string {
|
||||
const fmt = pattern ?? _format;
|
||||
const zone = normalizeTimezone(tz ?? getDisplayTimezone());
|
||||
if (typeof time === 'number') {
|
||||
if (isDailyTimeframe(timeframe) && !patternHasTime(fmt)) {
|
||||
return formatUnixWithChartPattern(time, fmt, zone);
|
||||
}
|
||||
return formatUnixWithChartPattern(time, fmt, zone);
|
||||
}
|
||||
const unix = Math.floor(Date.UTC(time.year, time.month - 1, time.day) / 1000);
|
||||
return formatUnixWithChartPattern(unix, fmt, zone);
|
||||
}
|
||||
|
||||
/** LWC X축 눈금 */
|
||||
export function formatLwcTickMarkWithPattern(
|
||||
time: Time,
|
||||
tickMarkType: TickMarkType,
|
||||
timeframe?: Timeframe,
|
||||
tz?: string,
|
||||
pattern?: string,
|
||||
): string {
|
||||
const fmt = normalizeChartTimeFormat(pattern ?? _format);
|
||||
const zone = normalizeTimezone(tz ?? getDisplayTimezone());
|
||||
const unixSec = typeof time === 'number'
|
||||
? time
|
||||
: time && typeof time === 'object' && 'year' in time
|
||||
? Math.floor(Date.UTC(time.year, time.month - 1, time.day) / 1000)
|
||||
: null;
|
||||
if (unixSec == null) return '';
|
||||
const parts = getZonedParts(unixSec, zone);
|
||||
const { date: datePat, time: timePat } = splitChartTimePattern(fmt);
|
||||
|
||||
switch (tickMarkType) {
|
||||
case TickMarkType.Year:
|
||||
return parts.yyyy || applyChartTimePattern('yyyy', parts);
|
||||
case TickMarkType.Month:
|
||||
if (datePat.includes('MM')) return applyChartTimePattern(datePat.includes('yyyy') ? 'yyyy-MM' : 'MM', parts);
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, month: 'short' }).format(new Date(unixSec * 1000));
|
||||
case TickMarkType.DayOfMonth:
|
||||
if (isDailyTimeframe(timeframe) || !patternHasTime(fmt)) {
|
||||
return datePat ? applyChartTimePattern(datePat, parts) : parts.dd;
|
||||
}
|
||||
return datePat ? applyChartTimePattern(datePat, parts) : parts.dd;
|
||||
case TickMarkType.Time:
|
||||
return timePat ? applyChartTimePattern(timePat.replace(/ss/g, ''), parts) : `${parts.HH}:${parts.mm}`;
|
||||
case TickMarkType.TimeWithSeconds:
|
||||
return timePat ? applyChartTimePattern(timePat, parts) : `${parts.HH}:${parts.mm}:${parts.ss}`;
|
||||
default:
|
||||
return formatUnixWithChartPattern(unixSec, fmt, zone);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** 차트 pane에서 지표 설정 저장 직후 — App 전역 기본값 병합 1회 스킵 */
|
||||
let skipNextChartDefaultsSync = false;
|
||||
|
||||
export function markChartIndicatorSaveCommitted(): void {
|
||||
skipNextChartDefaultsSync = true;
|
||||
}
|
||||
|
||||
export function consumeSkipNextChartDefaultsSync(): boolean {
|
||||
if (!skipNextChartDefaultsSync) return false;
|
||||
skipNextChartDefaultsSync = false;
|
||||
return true;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
|
||||
| 'paper' | 'trend-search' | 'alert' | 'network' | 'admin';
|
||||
|
||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings', 'verification-board',
|
||||
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'notifications', 'settings', 'verification-board',
|
||||
];
|
||||
|
||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
@@ -36,7 +36,7 @@ export const MENU_LABELS: Record<string, string> = {
|
||||
strategy: '투자전략',
|
||||
'strategy-editor': '전략편집기',
|
||||
backtest: '백테스팅',
|
||||
notifications: '알림',
|
||||
notifications: '알림목록',
|
||||
settings: '설정',
|
||||
settings_general: '설정 · 일반',
|
||||
settings_chart: '설정 · 차트',
|
||||
|
||||
@@ -276,7 +276,8 @@ function wrapTimeframes(candleTypes: string[], root: LogicNode): LogicNode {
|
||||
|
||||
/** 편집기 상태 → 저장/평가용 DSL (TIMEFRAME 래핑) */
|
||||
export function encodeConditionForSave(state: EditorConditionState): LogicNode | null {
|
||||
const branches = collectEditorBranches(state).filter(
|
||||
const synced = syncEditorStateCandleTypesForSave(state);
|
||||
const branches = collectEditorBranches(synced).filter(
|
||||
(b): b is ConditionBranch & { root: LogicNode } => b.root != null,
|
||||
);
|
||||
|
||||
@@ -414,6 +415,15 @@ export function syncBuySellPrimaryStartCandleTypes(
|
||||
};
|
||||
}
|
||||
|
||||
/** 저장 직전 — 각 START 분봉을 조건 트리 left/rightCandleType 에 반영 */
|
||||
export function syncEditorStateCandleTypesForSave(state: EditorConditionState): EditorConditionState {
|
||||
let next = state;
|
||||
for (const section of collectStartSections(state)) {
|
||||
next = updateStartCandleTypes(next, section.startId, section.candleTypes);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 저장 직전 — 양쪽 START 분봉 합집합을 각 탭에 반영 (매수만 설정한 경우 매도 DSL도 동기화) */
|
||||
export function alignBuySellStartCandleTypesForSave(
|
||||
buy: EditorConditionState,
|
||||
@@ -423,7 +433,11 @@ export function alignBuySellStartCandleTypesForSave(
|
||||
...getStartCandleTypes(buy.startMeta[START_NODE_ID]),
|
||||
...getStartCandleTypes(sell.startMeta[START_NODE_ID]),
|
||||
]);
|
||||
return syncBuySellPrimaryStartCandleTypes(buy, sell, merged);
|
||||
const synced = syncBuySellPrimaryStartCandleTypes(buy, sell, merged);
|
||||
return {
|
||||
buy: syncEditorStateCandleTypesForSave(synced.buy),
|
||||
sell: syncEditorStateCandleTypesForSave(synced.sell),
|
||||
};
|
||||
}
|
||||
|
||||
/** 저장된 DSL에서 Logic Expression용 분기 목록 */
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
encodeConditionForSave,
|
||||
mergeStartMetaForLoad,
|
||||
normalizeStartCombineOp,
|
||||
syncEditorStateCandleTypesForSave,
|
||||
type EditorConditionState,
|
||||
} from './strategyConditionSerde';
|
||||
import { getStartCandleTypes, normalizeStartCandleType } from './strategyStartNodes';
|
||||
@@ -59,8 +60,20 @@ export function collectUiEvaluationTimeframes(
|
||||
return collectFromStrategyDto(strategy);
|
||||
}
|
||||
|
||||
function evaluationTimeframesMatch(uiTfs: string[], serverTfs: string[]): boolean {
|
||||
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
||||
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
||||
const missingOnServer = uiTfs.some(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
||||
const extraOnServer = [...serverSet].some(tf => !uiSet.has(tf));
|
||||
if (!missingOnServer && !extraOnServer) return true;
|
||||
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 분봉과 서버 DSL 불일치 시 안내.
|
||||
* UI 분봉과 서버 DSL 불일치 시 자동 동기화 시도 후, 여전히 다르면 안내.
|
||||
* @returns false — 진행 불가(저장 필요)
|
||||
*/
|
||||
export async function warnStrategyTimeframeMismatch(
|
||||
@@ -68,7 +81,8 @@ export async function warnStrategyTimeframeMismatch(
|
||||
strategy?: StrategyDto | null,
|
||||
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
|
||||
): Promise<boolean> {
|
||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strategy, editorState);
|
||||
const strat = strategy ?? await loadStrategy(strategyId);
|
||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat, editorState);
|
||||
if (uiTfs.length === 0) return true;
|
||||
|
||||
let serverTfs: string[];
|
||||
@@ -77,18 +91,27 @@ export async function warnStrategyTimeframeMismatch(
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true;
|
||||
|
||||
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(strategyId, strat);
|
||||
if (synced) {
|
||||
try {
|
||||
serverTfs = await loadStrategyTimeframes(strategyId);
|
||||
if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
||||
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
||||
const missingOnServer = uiTfs.filter(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
||||
const extraOnServer = [...serverSet].filter(tf => !uiSet.has(tf));
|
||||
|
||||
if (missingOnServer.length === 0 && extraOnServer.length === 0) return true;
|
||||
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
|
||||
const uiLabel = uiTfs.map(tf => normalizeStartCandleType(tf)).join(', ') || '1m';
|
||||
const serverLabel = [...serverSet].join(', ') || '1m';
|
||||
if (uiSet.size === serverSet.size && [...uiSet].every(tf => serverSet.has(tf))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const uiLabel = uiTfs.map(tf => normalizeStartCandleType(tf)).join(', ') || '1m';
|
||||
const serverLabel = [...serverSet].join(', ') || '1m';
|
||||
window.alert(
|
||||
`전략 화면에는 ${uiLabel} 봉으로 설정되어 있지만, 서버에는 ${serverLabel} 만 저장되어 있습니다.\n\n`
|
||||
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 DB에 반영한 뒤 실시간 체크를 켜 주세요.',
|
||||
@@ -105,13 +128,14 @@ function buildEditorStateFromStrategy(
|
||||
(side === 'buy' ? strategy.buyCondition : strategy.sellCondition) as LogicNode | null,
|
||||
);
|
||||
const snap = layout?.[side];
|
||||
return {
|
||||
const merged: EditorConditionState = {
|
||||
root: decoded.root,
|
||||
startMeta: mergeStartMetaForLoad(decoded.startMeta, snap?.startMeta),
|
||||
extraStartIds: snap?.extraStartIds?.length ? snap.extraStartIds : decoded.extraStartIds,
|
||||
extraRoots: snap?.extraRoots ?? decoded.extraRoots,
|
||||
startCombineOp: normalizeStartCombineOp(snap?.startCombineOp ?? decoded.startCombineOp),
|
||||
};
|
||||
return syncEditorStateCandleTypesForSave(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +145,7 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
strategyId: number,
|
||||
strategy?: StrategyDto | null,
|
||||
): Promise<boolean> {
|
||||
const strat = strategy ?? await loadStrategy(strategyId);
|
||||
const strat = (await loadStrategy(strategyId)) ?? strategy ?? null;
|
||||
if (!strat) return true;
|
||||
|
||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat);
|
||||
@@ -133,14 +157,18 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf)));
|
||||
const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf)));
|
||||
const missingOnServer = uiTfs.filter(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
||||
if (missingOnServer.length === 0) return true;
|
||||
|
||||
if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true;
|
||||
|
||||
const layout = strat.flowLayout;
|
||||
if (!layout) {
|
||||
return warnStrategyTimeframeMismatch(strategyId, strat);
|
||||
try {
|
||||
await repairStrategyTimeframes(strategyId);
|
||||
const repaired = await loadStrategyTimeframes(strategyId);
|
||||
return evaluationTimeframesMatch(uiTfs, repaired);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const buyState = buildEditorStateFromStrategy(strat, layout, 'buy');
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TickMarkType, type Time } from 'lightweight-charts';
|
||||
import type { Timeframe } from '../types';
|
||||
import {
|
||||
formatLwcTickMarkWithPattern,
|
||||
formatLwcTimeWithPattern,
|
||||
getChartTimeFormat,
|
||||
} from './chartTimeFormat';
|
||||
|
||||
export const DEFAULT_DISPLAY_TIMEZONE = 'Asia/Seoul';
|
||||
|
||||
@@ -68,10 +73,6 @@ export function getTimezoneAbbr(tz: string): string {
|
||||
return TIMEZONE_OPTIONS.find(o => o.id === id)?.abbr ?? id.split('/').pop() ?? 'TZ';
|
||||
}
|
||||
|
||||
function isDailyTimeframe(tf?: string): boolean {
|
||||
return tf === '1D' || tf === '1W' || tf === '1M';
|
||||
}
|
||||
|
||||
/** 하단 바·시계 등 HH:mm:ss */
|
||||
export function formatUnixClock(unixSec: number, tz?: string, withSeconds = true): string {
|
||||
if (!unixSec) return '–';
|
||||
@@ -90,24 +91,7 @@ export function formatUnixClock(unixSec: number, tz?: string, withSeconds = true
|
||||
/** 차트 축·범례용 */
|
||||
export function formatUnixForChart(unixSec: number, timeframe?: Timeframe, tz?: string): string {
|
||||
if (!unixSec) return '';
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
const d = new Date(unixSec * 1000);
|
||||
if (isDailyTimeframe(timeframe)) {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
return formatLwcTimeWithPattern(unixSec, timeframe, tz ?? _tz, getChartTimeFormat());
|
||||
}
|
||||
|
||||
/** LWC Time → unix 초 (없으면 null) */
|
||||
@@ -126,17 +110,7 @@ export function formatLwcTime(
|
||||
timeframe?: Timeframe,
|
||||
tz?: string,
|
||||
): string {
|
||||
if (typeof time === 'number') {
|
||||
return formatUnixForChart(time, timeframe, tz);
|
||||
}
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
const utc = Date.UTC(time.year, time.month - 1, time.day);
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date(utc));
|
||||
return formatLwcTimeWithPattern(time, timeframe, tz ?? _tz, getChartTimeFormat());
|
||||
}
|
||||
|
||||
/** LWC X축 눈금 — 선택한 표시 시간대 기준 (기본 포맷터는 브라우저 로컬 TZ 사용) */
|
||||
@@ -146,43 +120,13 @@ export function formatLwcTickMark(
|
||||
timeframe?: Timeframe,
|
||||
tz?: string,
|
||||
): string {
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
const unixSec = lwcTimeToUnix(time);
|
||||
if (unixSec == null) return '';
|
||||
const d = new Date(unixSec * 1000);
|
||||
|
||||
switch (tickMarkType) {
|
||||
case TickMarkType.Year:
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric' }).format(d);
|
||||
case TickMarkType.Month:
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, month: 'short' }).format(d);
|
||||
case TickMarkType.DayOfMonth:
|
||||
if (isDailyTimeframe(timeframe)) {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(d);
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, day: 'numeric' }).format(d);
|
||||
case TickMarkType.Time:
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
case TickMarkType.TimeWithSeconds:
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
default:
|
||||
return formatUnixForChart(unixSec, timeframe, zone);
|
||||
}
|
||||
return formatLwcTickMarkWithPattern(
|
||||
time,
|
||||
tickMarkType,
|
||||
timeframe,
|
||||
tz ?? _tz,
|
||||
getChartTimeFormat(),
|
||||
);
|
||||
}
|
||||
|
||||
/** 하단 바 표시: `06:36:00 KST` */
|
||||
|
||||
@@ -91,7 +91,7 @@ export function buildSignalDetailRows(item: TradeSignalDisplaySource): Array<{ l
|
||||
{ label: '종목', value: primary !== secondary ? `${secondary} · ${primary}` : secondary },
|
||||
{ label: '매매 구분', value: getSignalTypeKo(item.signalType), highlight: true },
|
||||
{ label: '기준 가격', value: formatSignalPrice(item.price), highlight: true },
|
||||
{ label: '캔들 시각', value: `${formatSignalTime(item.candleTime)} · ${item.candleType ?? '1m'}` },
|
||||
{ label: '캔들 시각', value: `${formatSignalTime(item.candleTime)} · ${formatCandleTypeKo(item.candleType)}` },
|
||||
];
|
||||
|
||||
if (item.receivedAt) {
|
||||
|
||||
Reference in New Issue
Block a user