331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
/**
|
|
* LiveStrategyPanel
|
|
*
|
|
* 실시간 전략 체크 — 전역 설정 UI.
|
|
* 관심종목(★) 등록 종목 전체가 체크 대상이며, 여기서 전략·실행 방식을 지정합니다.
|
|
*/
|
|
import React, { useState, useCallback } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import type { Theme } from '../types';
|
|
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
|
import {
|
|
repairStrategyTimeframes,
|
|
saveLiveStrategySettings,
|
|
type LiveStrategySettingsDto,
|
|
} from '../utils/backendApi';
|
|
import {
|
|
syncStrategyTimeframesFromLayoutIfNeeded,
|
|
warnStrategyTimeframeMismatch,
|
|
} from '../utils/strategyTimeframeSync';
|
|
import { getKoreanName } from '../utils/marketNameCache';
|
|
|
|
interface Strategy {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
interface LiveStrategyPanelProps {
|
|
theme: Theme;
|
|
market: string;
|
|
strategies: Strategy[];
|
|
settings: LiveStrategySettingsDto;
|
|
watchlistCount?: number;
|
|
monitoredMarkets?: string[];
|
|
/** 가상투자 세션에서 제어 — 차트 팝업은 읽기 전용 */
|
|
virtualDriven?: boolean;
|
|
onClearMarkers?: () => void;
|
|
onClose?: () => void;
|
|
onSettingsChange?: (settings: LiveStrategySettingsDto) => void;
|
|
}
|
|
|
|
const ACCENT = '#3f7ef5';
|
|
const ACCENT_GLOW = 'rgba(63,126,245,0.22)';
|
|
|
|
const CandleIcon: React.FC<{ color: string }> = ({ color }) => (
|
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect x="6" y="9" width="5" height="12" rx="1" fill={color} opacity="0.9"/>
|
|
<line x1="8.5" y1="5" x2="8.5" y2="9" stroke={color} strokeWidth="1.5"/>
|
|
<line x1="8.5" y1="21" x2="8.5" y2="25" stroke={color} strokeWidth="1.5"/>
|
|
<rect x="17" y="7" width="5" height="9" rx="1" fill="var(--down,#4dabf7)" opacity="0.8"/>
|
|
<line x1="19.5" y1="4" x2="19.5" y2="7" stroke="var(--down,#4dabf7)" strokeWidth="1.5"/>
|
|
<line x1="19.5" y1="16" x2="19.5" y2="20" stroke="var(--down,#4dabf7)" strokeWidth="1.5"/>
|
|
</svg>
|
|
);
|
|
|
|
const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
|
theme, market, strategies, settings,
|
|
watchlistCount = 0, monitoredMarkets = [], virtualDriven = false,
|
|
onClearMarkers, onClose, onSettingsChange,
|
|
}) => {
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const {
|
|
panelRef,
|
|
dragging,
|
|
onHeaderPointerDown,
|
|
headerTouchStyle,
|
|
panelStyle,
|
|
headerCursor,
|
|
} = useDraggablePanel({
|
|
centerOnMount: true,
|
|
initialPosition: { x: 40, y: 40 },
|
|
});
|
|
|
|
const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
|
|
if (virtualDriven) return;
|
|
const next: LiveStrategySettingsDto = { ...settings, ...patch, market };
|
|
const prev = settings;
|
|
if (next.isLiveCheck && next.strategyId != null) {
|
|
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(next.strategyId);
|
|
if (!synced) return;
|
|
const ok = await warnStrategyTimeframeMismatch(next.strategyId);
|
|
if (!ok) return;
|
|
try {
|
|
await repairStrategyTimeframes(next.strategyId);
|
|
} catch {
|
|
/* repair optional */
|
|
}
|
|
}
|
|
onSettingsChange?.(next);
|
|
setSaving(true);
|
|
try {
|
|
const saved = await saveLiveStrategySettings(next);
|
|
if (saved) {
|
|
onSettingsChange?.(saved);
|
|
onClearMarkers?.();
|
|
} else {
|
|
onSettingsChange?.(prev);
|
|
window.alert('실시간 전략 설정 저장에 실패했습니다. 네트워크·서버 상태를 확인해 주세요.');
|
|
}
|
|
} catch {
|
|
onSettingsChange?.(prev);
|
|
window.alert('실시간 전략 설정 저장 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [settings, market, onClearMarkers, onSettingsChange, virtualDriven]);
|
|
|
|
const isOn = settings.isLiveCheck;
|
|
const execType = settings.executionType;
|
|
const stratId = settings.strategyId;
|
|
const monCount = monitoredMarkets.length;
|
|
const selectedStrategy = strategies.find(s => s.id === stratId);
|
|
|
|
const strategyTimeframes = settings.strategyCandleTypes?.length
|
|
? settings.strategyCandleTypes.join(', ')
|
|
: '1m';
|
|
const execLabel = execType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후';
|
|
const posModeLabel = (settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'
|
|
? '보유 자산 기준'
|
|
: '순수 지표 기준';
|
|
|
|
const panel = (
|
|
<div className={`lsp-overlay lsp-overlay--${theme}`}>
|
|
<div
|
|
ref={panelRef}
|
|
className="lsp-modal app-popup-shell"
|
|
style={{
|
|
...panelStyle,
|
|
zIndex: 10000,
|
|
cursor: dragging ? 'grabbing' : undefined,
|
|
'--lsp-accent': ACCENT,
|
|
'--lsp-accent-glow': ACCENT_GLOW,
|
|
} as React.CSSProperties}
|
|
onMouseDown={e => e.stopPropagation()}
|
|
>
|
|
<div
|
|
className="gc-popup-header lsp-header"
|
|
onPointerDown={onHeaderPointerDown}
|
|
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
|
>
|
|
<div className="lsp-header-left">
|
|
<span className="lsp-badge" style={{ background: ACCENT }}>LIVE</span>
|
|
<span className="lsp-header-title">
|
|
STRATEGY CHECK
|
|
<span className="lsp-header-ko"> (실시간 전략 체크)</span>
|
|
</span>
|
|
</div>
|
|
{saving && <span className="lsp-saving">저장 중…</span>}
|
|
{onClose && (
|
|
<button type="button" className="lsp-close" onClick={onClose} title="닫기">✕</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="lsp-summary-section">
|
|
<div className="lsp-summary-row">
|
|
<span className="lsp-summary-label">관심 종목</span>
|
|
<span className="lsp-summary-value">{watchlistCount}개</span>
|
|
</div>
|
|
<div className="lsp-summary-row">
|
|
<span className="lsp-summary-label">체크 대상</span>
|
|
<span className={`lsp-summary-value${monCount > 0 ? ' lsp-summary-value--active' : ''}`}>
|
|
{monCount}개
|
|
</span>
|
|
</div>
|
|
{isOn && stratId && watchlistCount > 0 && (
|
|
<p className="lsp-summary-hint">
|
|
★ 관심종목으로 등록한 종목이 자동으로 전략 체크 대상입니다.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{monCount > 0 && (
|
|
<div className="lsp-info-card">
|
|
<div className="lsp-info-icon">
|
|
<CandleIcon color={ACCENT} />
|
|
</div>
|
|
<div className="lsp-info-body">
|
|
<div className="lsp-info-row">
|
|
<span className="lsp-info-label">모니터링</span>
|
|
<span className="lsp-info-val lsp-monitor-chips">
|
|
{monitoredMarkets.slice(0, 8).map(m => (
|
|
<span key={m} className="lsp-monitor-chip" title={m}>
|
|
{getKoreanName(m)}
|
|
</span>
|
|
))}
|
|
{monCount > 8 && <span className="lsp-monitor-chip">+{monCount - 8}</span>}
|
|
</span>
|
|
</div>
|
|
{isOn && selectedStrategy && (
|
|
<>
|
|
<div className="lsp-info-row">
|
|
<span className="lsp-info-label">적용 전략</span>
|
|
<span className="lsp-info-val">{selectedStrategy.name}</span>
|
|
</div>
|
|
<div className="lsp-info-row">
|
|
<span className="lsp-info-label">평가 분봉</span>
|
|
<span className="lsp-info-val" title="전략 편집기 START에서 설정">{strategyTimeframes}</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="lsp-body">
|
|
<div className="lsp-field-row">
|
|
<span className="lsp-field-label">실시간 체크</span>
|
|
<label className="lsp-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={isOn}
|
|
disabled={virtualDriven}
|
|
onChange={e => persist({ isLiveCheck: e.target.checked })}
|
|
/>
|
|
<span className="lsp-toggle-slider" />
|
|
</label>
|
|
</div>
|
|
|
|
<div className={`lsp-field-row${!isOn ? ' lsp-field-row--disabled' : ''}`}>
|
|
<span className="lsp-field-label">전략 선택</span>
|
|
<select
|
|
className="lsp-select"
|
|
disabled={virtualDriven || !isOn}
|
|
value={stratId ?? ''}
|
|
onChange={e => persist({ strategyId: e.target.value ? Number(e.target.value) : null })}
|
|
>
|
|
<option value="">전략 선택…</option>
|
|
{strategies.map(s => (
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
|
|
<div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}>
|
|
<span className="lsp-section-label">실행 방식</span>
|
|
<div className="lsp-option-group">
|
|
<label className={`lsp-option${execType === 'CANDLE_CLOSE' ? ' lsp-option--active' : ''}`}>
|
|
<input
|
|
type="radio"
|
|
name="execType"
|
|
value="CANDLE_CLOSE"
|
|
checked={execType === 'CANDLE_CLOSE'}
|
|
disabled={virtualDriven || !isOn}
|
|
onChange={() => persist({ executionType: 'CANDLE_CLOSE' })}
|
|
/>
|
|
<span className="lsp-option-text">
|
|
<span className="lsp-option-title">봉 마감 직후</span>
|
|
<span className="lsp-option-desc">완성된 캔들 기준으로 1회 판정</span>
|
|
</span>
|
|
</label>
|
|
<label className={`lsp-option${execType === 'REALTIME_TICK' ? ' lsp-option--active' : ''}`}>
|
|
<input
|
|
type="radio"
|
|
name="execType"
|
|
value="REALTIME_TICK"
|
|
checked={execType === 'REALTIME_TICK'}
|
|
disabled={virtualDriven || !isOn}
|
|
onChange={() => persist({ executionType: 'REALTIME_TICK' })}
|
|
/>
|
|
<span className="lsp-option-text">
|
|
<span className="lsp-option-title">실시간 틱 (3초)</span>
|
|
<span className="lsp-option-desc">진행 중인 캔들 기준, 3초 주기 판정</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}>
|
|
<span className="lsp-section-label">시그널 모드</span>
|
|
<div className="lsp-option-group">
|
|
<label className={`lsp-option${(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY' ? ' lsp-option--active' : ''}`}>
|
|
<input
|
|
type="radio"
|
|
name="lsp-posMode"
|
|
value="LONG_ONLY"
|
|
checked={(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
|
|
disabled={virtualDriven || !isOn}
|
|
onChange={() => persist({ positionMode: 'LONG_ONLY' })}
|
|
/>
|
|
<span className="lsp-option-text">
|
|
<span className="lsp-option-title">보유 자산 기준</span>
|
|
<span className="lsp-option-desc">매수 이력 있을 때만 매도 허용</span>
|
|
</span>
|
|
</label>
|
|
<label className={`lsp-option${settings.positionMode === 'SIGNAL_ONLY' ? ' lsp-option--active' : ''}`}>
|
|
<input
|
|
type="radio"
|
|
name="lsp-posMode"
|
|
value="SIGNAL_ONLY"
|
|
checked={settings.positionMode === 'SIGNAL_ONLY'}
|
|
disabled={virtualDriven || !isOn}
|
|
onChange={() => persist({ positionMode: 'SIGNAL_ONLY' })}
|
|
/>
|
|
<span className="lsp-option-text">
|
|
<span className="lsp-option-title">순수 지표 기준</span>
|
|
<span className="lsp-option-desc">포지션 무관, 지표 충족 시 즉시 매도</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{isOn && !stratId && (
|
|
<p className="lsp-hint">전략을 선택하면 관심 {watchlistCount}개 종목에 적용됩니다.</p>
|
|
)}
|
|
|
|
{isOn && (
|
|
<div className="lsp-status-bar">
|
|
<span className={`lsp-dot${isOn ? ' lsp-dot--on' : ''}`} />
|
|
<span className="lsp-status-text">
|
|
{stratId
|
|
? `${selectedStrategy?.name ?? '전략'} · ${strategyTimeframes} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
|
|
: '전략을 선택하세요'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<p className="lsp-disclaimer">
|
|
※ 실시간 체크 ON 시 관심종목(★) 전체에 선택한 전략이 적용됩니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (typeof document === 'undefined') return null;
|
|
return createPortal(panel, document.body);
|
|
};
|
|
|
|
export default LiveStrategyPanel;
|