매매 시그널 알림 화면 수정

This commit is contained in:
Macbook
2026-05-29 00:46:20 +09:00
parent cbad62a5b0
commit e43b5cbd5a
45 changed files with 2186 additions and 415 deletions
+111 -10
View File
@@ -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 {
+1 -1
View File
@@ -100,7 +100,7 @@ export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
P('메뉴-백테스팅-분석 차트', 'analysis'),
// ── 상단 메뉴바 기타 ──
P('메뉴-알림 목록', 'notifications', '시그널목록'),
P('메뉴-알림목록', 'notifications', '알림목록'),
P('메뉴-테마 전환', 'theme', '다크', '라이트'),
P('메뉴-로그인', 'login', 'auth'),
];
+2
View File
@@ -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 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
+214
View File
@@ -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;
}
+2 -2
View File
@@ -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: '설정 · 차트',
+16 -2
View File
@@ -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용 분기 목록 */
+44 -16
View File
@@ -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');
+14 -70
View File
@@ -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` */
+1 -1
View File
@@ -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) {