알림 전체닫기 오류 수정
This commit is contained in:
@@ -2028,7 +2028,7 @@ function App() {
|
||||
onPaperOrderFilled={handlePaperOrderFilled}
|
||||
onOpenSettings={() => {
|
||||
if (!formalLogin) { requireFormalLogin(); return; }
|
||||
setSettingsInitialCategory('paper');
|
||||
setSettingsInitialCategory('trading');
|
||||
setMenuPage('settings');
|
||||
}}
|
||||
onGoChart={m => {
|
||||
@@ -2200,6 +2200,10 @@ function App() {
|
||||
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
|
||||
paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct}
|
||||
onPaperAutoTradeBudgetPct={v => saveAppDef({ paperAutoTradeBudgetPct: v })}
|
||||
paperOrderFillMode={appDefaults.paperOrderFillMode}
|
||||
onPaperOrderFillMode={v => saveAppDef({ paperOrderFillMode: v })}
|
||||
paperOrderAutoCancelPct={appDefaults.paperOrderAutoCancelPct}
|
||||
onPaperOrderAutoCancelPct={v => saveAppDef({ paperOrderAutoCancelPct: v })}
|
||||
virtualTargetMaxCount={appDefaults.virtualTargetMaxCount}
|
||||
onVirtualTargetMaxCount={v => saveAppDef({ virtualTargetMaxCount: clampVirtualTargetMax(v) })}
|
||||
trendSearchSettings={appDefaults.trendSearchSettings}
|
||||
|
||||
@@ -143,6 +143,10 @@ interface SettingsPageProps {
|
||||
onPaperAutoTradeEnabled?: (v: boolean) => void;
|
||||
paperAutoTradeBudgetPct?: number;
|
||||
onPaperAutoTradeBudgetPct?: (v: number) => void;
|
||||
paperOrderFillMode?: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL';
|
||||
onPaperOrderFillMode?: (v: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL') => void;
|
||||
paperOrderAutoCancelPct?: number;
|
||||
onPaperOrderAutoCancelPct?: (v: number) => void;
|
||||
virtualTargetMaxCount?: number;
|
||||
onVirtualTargetMaxCount?: (v: number) => void;
|
||||
onPaperAccountReset?: () => void;
|
||||
@@ -250,6 +254,15 @@ const IcVirtual = () => (
|
||||
<path d="M10 11h2"/>
|
||||
</svg>
|
||||
);
|
||||
const IcTrading = () => (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="5" width="16" height="12" rx="2"/>
|
||||
<line x1="7" y1="9" x2="15" y2="9"/>
|
||||
<line x1="7" y1="12" x2="12" y2="12"/>
|
||||
<path d="M16 2l2 2-2 2"/>
|
||||
<path d="M6 20l-2-2 2-2"/>
|
||||
</svg>
|
||||
);
|
||||
const IcLive = () => (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="9"/>
|
||||
@@ -287,8 +300,7 @@ const ALL_CATEGORIES: Category[] = [
|
||||
{ id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
|
||||
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
|
||||
{ id: 'strategy', label: '전략 설정', icon: <IcStrategy />, desc: '투자 전략 기본 파라미터 및 조건 설정' },
|
||||
{ id: 'paper', label: '모의투자', icon: <IcPaper />, desc: '모의투자 활성화, 자동매매, 초기 자본·수수료·슬리피지, 계좌 초기화' },
|
||||
{ id: 'virtual', label: '가상매매', icon: <IcVirtual />, desc: '가상매매 화면 투자대상 목록 한도 등 가상매매 전용 설정' },
|
||||
{ id: 'trading', label: '매매설정', icon: <IcTrading />, desc: '모의투자·가상매매 통합 설정 — 자동매매, 미체결 주문 처리, 계좌·비용 등' },
|
||||
{ id: 'live', label: '실거래', icon: <IcLive />, desc: '매매 운영 모드, 업비트 API 키, 실거래 자동매매' },
|
||||
{ id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' },
|
||||
{ id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
|
||||
@@ -474,29 +486,226 @@ const PaperTradingSettingsPanel: React.FC<PaperTradingSettingsPanelProps> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
const VirtualTradingSettingsPanel: React.FC<{
|
||||
// ── 통합 매매설정 패널 ────────────────────────────────────────────────────────
|
||||
|
||||
interface TradingSettingsPanelProps {
|
||||
paperTradingEnabled?: boolean;
|
||||
onPaperTradingEnabled?: (v: boolean) => void;
|
||||
paperInitialCapital?: number;
|
||||
onPaperInitialCapital?: (v: number) => void;
|
||||
paperFeeRatePct?: number;
|
||||
onPaperFeeRatePct?: (v: number) => void;
|
||||
paperSlippagePct?: number;
|
||||
onPaperSlippagePct?: (v: number) => void;
|
||||
paperMinOrderKrw?: number;
|
||||
onPaperMinOrderKrw?: (v: number) => void;
|
||||
paperAutoTradeEnabled?: boolean;
|
||||
onPaperAutoTradeEnabled?: (v: boolean) => void;
|
||||
paperAutoTradeBudgetPct?: number;
|
||||
onPaperAutoTradeBudgetPct?: (v: number) => void;
|
||||
paperOrderFillMode?: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL';
|
||||
onPaperOrderFillMode?: (v: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL') => void;
|
||||
paperOrderAutoCancelPct?: number;
|
||||
onPaperOrderAutoCancelPct?: (v: number) => void;
|
||||
virtualTargetMaxCount?: number;
|
||||
onVirtualTargetMaxCount?: (v: number) => void;
|
||||
}> = ({ virtualTargetMaxCount = 20, onVirtualTargetMaxCount }) => (
|
||||
<SettingSection title="가상매매">
|
||||
<SettingRow
|
||||
label="투자대상 목록 최대 개수"
|
||||
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다."
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
className="stg-input stg-input--num"
|
||||
value={virtualTargetMaxCount}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<p className="stg-hint">
|
||||
모의투자 활성화·자동매매·계좌 비용은 설정의 「모의투자」 카테고리에서 관리합니다.
|
||||
</p>
|
||||
</SettingSection>
|
||||
onPaperAccountReset?: () => void;
|
||||
}
|
||||
|
||||
const TradingSettingsPanel: React.FC<TradingSettingsPanelProps> = ({
|
||||
paperTradingEnabled = true,
|
||||
onPaperTradingEnabled,
|
||||
paperInitialCapital = 10_000_000,
|
||||
onPaperInitialCapital,
|
||||
paperFeeRatePct = 0.05,
|
||||
onPaperFeeRatePct,
|
||||
paperSlippagePct = 0,
|
||||
onPaperSlippagePct,
|
||||
paperMinOrderKrw = 5000,
|
||||
onPaperMinOrderKrw,
|
||||
paperAutoTradeEnabled = false,
|
||||
onPaperAutoTradeEnabled,
|
||||
paperAutoTradeBudgetPct = 95,
|
||||
onPaperAutoTradeBudgetPct,
|
||||
paperOrderFillMode = 'LIMIT_ONLY',
|
||||
onPaperOrderFillMode,
|
||||
paperOrderAutoCancelPct = 1.0,
|
||||
onPaperOrderAutoCancelPct,
|
||||
virtualTargetMaxCount = 20,
|
||||
onVirtualTargetMaxCount,
|
||||
onPaperAccountReset,
|
||||
}) => (
|
||||
<>
|
||||
{/* ── 모의투자 ────────────────────────────────────────── */}
|
||||
<SettingSection title="모의투자">
|
||||
<SettingRow
|
||||
label="모의투자 활성화"
|
||||
desc="켜면 모의투자 화면·실시간 차트 매매 패널·전략 Match 자동매매가 모의 계좌로 체결됩니다."
|
||||
>
|
||||
<label className="stg-toggle">
|
||||
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
|
||||
{/* ── 자동매매 ─────────────────────────────────────────── */}
|
||||
<SettingSection title="자동매매">
|
||||
<SettingRow
|
||||
label="자동매매"
|
||||
desc="Match 충족 시 모의 계좌로 자동 매수·매도합니다. 모의투자·가상매매 화면 타이틀바에서도 변경할 수 있습니다."
|
||||
>
|
||||
<label className="stg-toggle">
|
||||
<input type="checkbox" checked={paperAutoTradeEnabled} onChange={e => onPaperAutoTradeEnabled?.(e.target.checked)} />
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="자동 매수 예산 (%)"
|
||||
desc="Match 매수 시 주문가능 현금 중 사용 비율. Match 매도는 해당 종목 보유 수량 전량 매도."
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
className="stg-input stg-input--num"
|
||||
value={paperAutoTradeBudgetPct}
|
||||
min={1} max={100} step={1}
|
||||
disabled={!paperAutoTradeEnabled}
|
||||
onChange={e => onPaperAutoTradeBudgetPct?.(Number(e.target.value))}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
|
||||
{/* ── 미체결 주문 처리 ─────────────────────────────────── */}
|
||||
<SettingSection title="미체결 주문 처리">
|
||||
<SettingRow
|
||||
label="체결 모드"
|
||||
desc="수동·자동매매 시 생성된 미체결 주문이 시세 변동 시 어떻게 처리될지 결정합니다."
|
||||
className="stg-row--block"
|
||||
>
|
||||
<div className="stg-radio-row">
|
||||
<label className="stg-radio-option stg-radio-option--cyan">
|
||||
<input
|
||||
type="radio"
|
||||
name="stg-fill-mode"
|
||||
value="LIMIT_ONLY"
|
||||
checked={paperOrderFillMode === 'LIMIT_ONLY'}
|
||||
onChange={() => onPaperOrderFillMode?.('LIMIT_ONLY')}
|
||||
/>
|
||||
<span className="stg-radio-option-text">
|
||||
<strong>A. 지정가 유지 (기본값)</strong>
|
||||
<span>매수: 시세 ≤ 주문가일 때만 체결 / 매도: 시세 ≥ 주문가일 때만 체결. 반대 방향으로 가격이 벗어나면 미체결 유지</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="stg-radio-option stg-radio-option--blue">
|
||||
<input
|
||||
type="radio"
|
||||
name="stg-fill-mode"
|
||||
value="NEXT_TICK"
|
||||
checked={paperOrderFillMode === 'NEXT_TICK'}
|
||||
onChange={() => onPaperOrderFillMode?.('NEXT_TICK')}
|
||||
/>
|
||||
<span className="stg-radio-option-text">
|
||||
<strong>B. 다음 틱 강제 체결</strong>
|
||||
<span>주문 생성 직후 첫 번째 시세 틱에서 가격 방향 무관하게 즉시 체결 (시장가와 동일한 효과)</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="stg-radio-option stg-radio-option--orange">
|
||||
<input
|
||||
type="radio"
|
||||
name="stg-fill-mode"
|
||||
value="AUTO_CANCEL"
|
||||
checked={paperOrderFillMode === 'AUTO_CANCEL'}
|
||||
onChange={() => onPaperOrderFillMode?.('AUTO_CANCEL')}
|
||||
/>
|
||||
<span className="stg-radio-option-text">
|
||||
<strong>C. 허용폭 초과 시 자동 취소</strong>
|
||||
<span>주문가 대비 설정한 % 이상 가격이 벗어나면 미체결 주문 자동 취소. 허용폭 내이면 지정가 조건으로 체결</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</SettingRow>
|
||||
{paperOrderFillMode === 'AUTO_CANCEL' && (
|
||||
<SettingRow label="자동 취소 허용 이탈폭 (%)" desc="주문가 대비 이 % 이상 가격이 벗어나면 미체결 주문이 자동 취소됩니다.">
|
||||
<input
|
||||
type="number"
|
||||
className="stg-input stg-input--num"
|
||||
value={paperOrderAutoCancelPct}
|
||||
min={0.1} max={50} step={0.1}
|
||||
onChange={e => onPaperOrderAutoCancelPct?.(Number(e.target.value))}
|
||||
/>
|
||||
<span className="stg-unit">%</span>
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingSection>
|
||||
|
||||
{/* ── 계좌·비용 ─────────────────────────────────────────── */}
|
||||
<SettingSection title="계좌·비용">
|
||||
<SettingRow label="초기 자본 (KRW)" desc="계좌 초기화 시 적용되는 시작 현금입니다.">
|
||||
<input
|
||||
type="number"
|
||||
className="stg-input stg-input--num"
|
||||
value={paperInitialCapital}
|
||||
min={100000} step={100000}
|
||||
onChange={e => onPaperInitialCapital?.(Number(e.target.value))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="수수료율 (%)" desc="체결 금액 기준 수수료 (업비트 KRW 마켓 기본 0.05% 참고).">
|
||||
<input
|
||||
type="number"
|
||||
className="stg-input stg-input--num"
|
||||
value={paperFeeRatePct}
|
||||
min={0} max={1} step={0.01}
|
||||
onChange={e => onPaperFeeRatePct?.(Number(e.target.value))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="슬리피지 (%)" desc="매수는 가격 상승, 매도는 가격 하락으로 체결가를 조정합니다.">
|
||||
<input
|
||||
type="number"
|
||||
className="stg-input stg-input--num"
|
||||
value={paperSlippagePct}
|
||||
min={0} max={5} step={0.01}
|
||||
onChange={e => onPaperSlippagePct?.(Number(e.target.value))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="최소 주문 금액 (KRW)" desc="이 금액 미만 주문은 거부됩니다.">
|
||||
<input
|
||||
type="number"
|
||||
className="stg-input stg-input--num"
|
||||
value={paperMinOrderKrw}
|
||||
min={1000} step={1000}
|
||||
onChange={e => onPaperMinOrderKrw?.(Number(e.target.value))}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
|
||||
{/* ── 가상매매 ─────────────────────────────────────────── */}
|
||||
<SettingSection title="가상매매">
|
||||
<SettingRow
|
||||
label="투자대상 목록 최대 개수"
|
||||
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다."
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
className="stg-input stg-input--num"
|
||||
value={virtualTargetMaxCount}
|
||||
min={1} max={100} step={1}
|
||||
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
|
||||
{/* ── 계좌 관리 ─────────────────────────────────────────── */}
|
||||
<SettingSection title="계좌 관리">
|
||||
<SettingRow
|
||||
label="계좌 초기화"
|
||||
desc="보유 종목·체결·원장·투자금 한도·미체결 주문을 삭제하고 초기 자본으로 현금을 되돌립니다."
|
||||
>
|
||||
<button type="button" className="stg-btn stg-btn--danger" onClick={onPaperAccountReset}>
|
||||
모의 계좌 초기화
|
||||
</button>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
</>
|
||||
);
|
||||
|
||||
const LiveTradingSettingsPanel: React.FC<{
|
||||
@@ -1764,6 +1973,10 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
onPaperAutoTradeEnabled,
|
||||
paperAutoTradeBudgetPct = 95,
|
||||
onPaperAutoTradeBudgetPct,
|
||||
paperOrderFillMode = 'LIMIT_ONLY',
|
||||
onPaperOrderFillMode,
|
||||
paperOrderAutoCancelPct = 1.0,
|
||||
onPaperOrderAutoCancelPct,
|
||||
virtualTargetMaxCount = 20,
|
||||
onVirtualTargetMaxCount,
|
||||
onPaperAccountReset,
|
||||
@@ -1913,31 +2126,34 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
<AdminSettingsPanel />
|
||||
</AdminPasswordGate>
|
||||
);
|
||||
case 'paper': return (
|
||||
<PaperTradingSettingsPanel
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
onPaperTradingEnabled={onPaperTradingEnabled}
|
||||
paperInitialCapital={paperInitialCapital}
|
||||
onPaperInitialCapital={onPaperInitialCapital}
|
||||
paperFeeRatePct={paperFeeRatePct}
|
||||
onPaperFeeRatePct={onPaperFeeRatePct}
|
||||
paperSlippagePct={paperSlippagePct}
|
||||
onPaperSlippagePct={onPaperSlippagePct}
|
||||
paperMinOrderKrw={paperMinOrderKrw}
|
||||
onPaperMinOrderKrw={onPaperMinOrderKrw}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
|
||||
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
|
||||
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
|
||||
onPaperAccountReset={onPaperAccountReset}
|
||||
/>
|
||||
);
|
||||
case 'virtual': return (
|
||||
<VirtualTradingSettingsPanel
|
||||
virtualTargetMaxCount={virtualTargetMaxCount}
|
||||
onVirtualTargetMaxCount={onVirtualTargetMaxCount}
|
||||
/>
|
||||
);
|
||||
case 'trading':
|
||||
case 'paper':
|
||||
case 'virtual':
|
||||
return (
|
||||
<TradingSettingsPanel
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
onPaperTradingEnabled={onPaperTradingEnabled}
|
||||
paperInitialCapital={paperInitialCapital}
|
||||
onPaperInitialCapital={onPaperInitialCapital}
|
||||
paperFeeRatePct={paperFeeRatePct}
|
||||
onPaperFeeRatePct={onPaperFeeRatePct}
|
||||
paperSlippagePct={paperSlippagePct}
|
||||
onPaperSlippagePct={onPaperSlippagePct}
|
||||
paperMinOrderKrw={paperMinOrderKrw}
|
||||
onPaperMinOrderKrw={onPaperMinOrderKrw}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
|
||||
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
|
||||
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
|
||||
paperOrderFillMode={paperOrderFillMode}
|
||||
onPaperOrderFillMode={onPaperOrderFillMode}
|
||||
paperOrderAutoCancelPct={paperOrderAutoCancelPct}
|
||||
onPaperOrderAutoCancelPct={onPaperOrderAutoCancelPct}
|
||||
virtualTargetMaxCount={virtualTargetMaxCount}
|
||||
onVirtualTargetMaxCount={onVirtualTargetMaxCount}
|
||||
onPaperAccountReset={onPaperAccountReset}
|
||||
/>
|
||||
);
|
||||
case 'live': return (
|
||||
<LiveTradingSettingsPanel
|
||||
tradingMode={tradingMode}
|
||||
|
||||
@@ -85,6 +85,30 @@ const PaperLeftSettingsTab: React.FC<Props> = ({
|
||||
onChange={e => patch({ paperAutoTradeBudgetPct: Number(e.target.value) })}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
<SettingsFieldRow label="미체결 주문 처리">
|
||||
<select
|
||||
className="vtd-trade-meta-value ptd-settings-input ptd-settings-select"
|
||||
value={defaults.paperOrderFillMode}
|
||||
onChange={e => patch({ paperOrderFillMode: e.target.value as 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL' })}
|
||||
>
|
||||
<option value="LIMIT_ONLY">A. 지정가 유지 (조건 충족 시 체결)</option>
|
||||
<option value="NEXT_TICK">B. 다음 틱 강제 체결 (시장가 동일)</option>
|
||||
<option value="AUTO_CANCEL">C. 허용폭 초과 시 자동 취소</option>
|
||||
</select>
|
||||
</SettingsFieldRow>
|
||||
{defaults.paperOrderFillMode === 'AUTO_CANCEL' && (
|
||||
<SettingsFieldRow label="허용 이탈폭 (%)">
|
||||
<input
|
||||
type="number"
|
||||
className="vtd-trade-meta-value ptd-settings-input"
|
||||
min={0.1}
|
||||
max={50}
|
||||
step={0.1}
|
||||
value={defaults.paperOrderAutoCancelPct}
|
||||
onChange={e => patch({ paperOrderAutoCancelPct: Number(e.target.value) })}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,8 +100,8 @@ function loadReadIds(): Set<string> {
|
||||
return new Set(ids ?? []);
|
||||
}
|
||||
|
||||
function saveReadIds(ids: Set<string>) {
|
||||
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } });
|
||||
function saveReadIds(ids: Set<string>, immediate = false) {
|
||||
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } }, immediate);
|
||||
}
|
||||
|
||||
function loadHiddenIds(): Set<string> {
|
||||
@@ -181,7 +181,10 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
||||
*/
|
||||
if (!opts?.rawServerList) {
|
||||
newlyArrived = items.filter(
|
||||
item => !item.isRead && !prev.some(p => p.id === item.id),
|
||||
item =>
|
||||
!item.isRead &&
|
||||
!readIdsRef.current.has(item.id) && // readIds 에 없는(진짜 새) 알림만
|
||||
!prev.some(p => p.id === item.id), // 이미 목록에 없는 알림만
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,43 +248,58 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
||||
|
||||
const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => {
|
||||
const id = makeId(signal);
|
||||
/**
|
||||
* 이전에 읽음 처리(닫기)한 동일 키를 readIds 에서 제거하던 로직을 삭제.
|
||||
* STOMP 재연결 시 동일 신호가 재수신되어도 사용자가 명시적으로 닫은 알림은
|
||||
* 다시 열리지 않아야 한다. 새로운 candleTime 을 가진 신호는 키가 달라
|
||||
* readIds 에 없으므로 정상적으로 미읽음 처리된다.
|
||||
*/
|
||||
const item: TradeNotificationItem = {
|
||||
...signal,
|
||||
id,
|
||||
dbId: signal.dbId,
|
||||
candleType: signal.candleType ?? '1m',
|
||||
isRead: false,
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
|
||||
// 배지(미확인 건수)는 팝업 설정과 무관하게 항상 갱신
|
||||
/**
|
||||
* 이미 읽음(닫기) 처리된 알림 ID 이면:
|
||||
* - allNotifications 에 이미 존재하면 isRead 상태를 건드리지 않는다.
|
||||
* - 새로운 항목이면 isRead: true 로 조용히 이력에만 추가한다.
|
||||
* - 토스트 팝업·알림음은 표시하지 않는다.
|
||||
* STOMP 재연결 시 동일 신호가 재수신되거나 20 초 폴링에서 새로 발견되어도
|
||||
* 사용자가 명시적으로 닫은 알림은 다시 열리지 않는다.
|
||||
*/
|
||||
const alreadyRead = readIdsRef.current.has(id);
|
||||
|
||||
setAllNotifications(prev => {
|
||||
const existing = prev.find(n => n.id === id);
|
||||
if (existing) {
|
||||
// 읽음 처리된 항목은 isRead 를 false 로 되돌리지 않는다
|
||||
if (existing.isRead || alreadyRead) return prev;
|
||||
// 미읽음 항목은 receivedAt 만 갱신
|
||||
return prev.map(n =>
|
||||
n.id === id
|
||||
? { ...n, ...item, isRead: false, receivedAt: item.receivedAt }
|
||||
: n,
|
||||
n.id === id ? { ...n, isRead: false, receivedAt: Date.now() } : n,
|
||||
);
|
||||
}
|
||||
const item: TradeNotificationItem = {
|
||||
...signal,
|
||||
id,
|
||||
dbId: signal.dbId,
|
||||
candleType: signal.candleType ?? '1m',
|
||||
isRead: alreadyRead,
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
return [item, ...prev].slice(0, MAX_HISTORY);
|
||||
});
|
||||
|
||||
// 이미 읽음 처리된 알림은 팝업·알림음 모두 생략
|
||||
if (alreadyRead) return;
|
||||
|
||||
if (soundEnabled) {
|
||||
playTradeAlertSound(alertSoundId);
|
||||
}
|
||||
|
||||
if (!popupEnabled) return;
|
||||
|
||||
const toastItem: TradeNotificationItem = {
|
||||
...signal,
|
||||
id,
|
||||
dbId: signal.dbId,
|
||||
candleType: signal.candleType ?? '1m',
|
||||
isRead: false,
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
setToastNotifications(prev => {
|
||||
const rest = prev.filter(n => n.id !== id);
|
||||
return [item, ...rest].slice(0, MAX_TOAST_QUEUE);
|
||||
return [toastItem, ...rest].slice(0, MAX_TOAST_QUEUE);
|
||||
});
|
||||
}, [popupEnabled, soundEnabled, alertSoundId]);
|
||||
|
||||
@@ -320,19 +338,20 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
||||
|
||||
const dismissAllToasts = useCallback(() => {
|
||||
/**
|
||||
* 전체 닫기 = 현재 토스트 + allNotifications 의 모든 미읽음 항목을 읽음 처리.
|
||||
* 기존에는 toastNotifications 에 있는 항목만 처리해서, 토스트에 없는 미읽음 항목은
|
||||
* 다음 refreshHistory 폴링 시 다시 팝업으로 나타나는 문제가 있었다.
|
||||
* 전체 닫기 = 토스트 + allNotifications 의 모든 항목을 즉시 읽음 처리.
|
||||
* patchUiPreferences immediate=true 로 디바운스 없이 즉시 DB에 반영해
|
||||
* 이후 폴링·STOMP 재수신 시에도 readIds 가 항상 최신 상태를 유지한다.
|
||||
*/
|
||||
const nextRead = new Set(readIdsRef.current);
|
||||
toastNotificationsRef.current.forEach(n => nextRead.add(n.id));
|
||||
// allNotifications 는 state 업데이트 전이므로 ref가 아닌 최신 snapshot이 필요.
|
||||
// setAllNotifications 콜백 안에서 nextRead를 채운 뒤 즉시 저장한다.
|
||||
setAllNotifications(prev => {
|
||||
const nextRead = new Set(readIdsRef.current);
|
||||
// 토스트 항목
|
||||
toastNotificationsRef.current.forEach(n => nextRead.add(n.id));
|
||||
// allNotifications 의 미읽음 항목 전체
|
||||
prev.forEach(n => nextRead.add(n.id));
|
||||
saveReadIds(nextRead);
|
||||
readIdsRef.current = nextRead;
|
||||
setReadIds(nextRead);
|
||||
// immediate=true 로 400ms 디바운스 없이 즉시 저장
|
||||
patchUiPreferences({ tradeNotifications: { readIds: [...nextRead] } }, true);
|
||||
return prev.map(n => ({ ...n, isRead: true }));
|
||||
});
|
||||
setToastNotifications([]);
|
||||
|
||||
@@ -184,6 +184,8 @@ export function resolveAppDefaults(s: AppSettingsDto) {
|
||||
paperMinOrderKrw: s.paperMinOrderKrw ?? 5000,
|
||||
paperAutoTradeEnabled: s.paperAutoTradeEnabled ?? false,
|
||||
paperAutoTradeBudgetPct: s.paperAutoTradeBudgetPct ?? 95,
|
||||
paperOrderFillMode: (s.paperOrderFillMode ?? 'LIMIT_ONLY') as 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL',
|
||||
paperOrderAutoCancelPct: s.paperOrderAutoCancelPct ?? 1.0,
|
||||
virtualTargetMaxCount: clampVirtualTargetMax(s.virtualTargetMaxCount ?? DEFAULT_VIRTUAL_TARGET_MAX),
|
||||
tradingMode: s.tradingMode ?? 'PAPER',
|
||||
liveAutoTradeEnabled: s.liveAutoTradeEnabled ?? false,
|
||||
|
||||
@@ -475,6 +475,15 @@ export interface AppSettingsDto {
|
||||
paperMinOrderKrw?: number;
|
||||
paperAutoTradeEnabled?: boolean;
|
||||
paperAutoTradeBudgetPct?: number;
|
||||
/**
|
||||
* 미체결 주문 체결 모드
|
||||
* LIMIT_ONLY : 지정가 조건 충족 시에만 체결 (기본)
|
||||
* NEXT_TICK : 다음 틱 강제 체결
|
||||
* AUTO_CANCEL : 허용 이탈폭(paperOrderAutoCancelPct %) 초과 시 자동 취소
|
||||
*/
|
||||
paperOrderFillMode?: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL';
|
||||
/** AUTO_CANCEL 모드 허용 이탈폭 (%) */
|
||||
paperOrderAutoCancelPct?: number;
|
||||
/** 가상매매 투자대상 목록 최대 종목 수 */
|
||||
virtualTargetMaxCount?: number;
|
||||
/** PAPER | LIVE | BOTH */
|
||||
|
||||
@@ -9,14 +9,14 @@ export type TopMenuId = MenuPage;
|
||||
|
||||
export type SettingsCategoryId =
|
||||
| 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy'
|
||||
| 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin';
|
||||
| 'trading' | 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin';
|
||||
|
||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board',
|
||||
];
|
||||
|
||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'virtual', 'live',
|
||||
'general', 'chart', 'indicators', 'backtest', 'strategy', 'trading', 'live',
|
||||
'trend-search', 'alert', 'network', 'admin',
|
||||
];
|
||||
|
||||
@@ -45,6 +45,7 @@ export const MENU_LABELS: Record<string, string> = {
|
||||
settings_indicators: '설정 · 보조지표',
|
||||
settings_backtest: '설정 · 백테스팅',
|
||||
settings_strategy: '설정 · 전략',
|
||||
settings_trading: '설정 · 매매설정',
|
||||
settings_paper: '설정 · 모의투자',
|
||||
settings_virtual: '설정 · 가상매매',
|
||||
settings_live: '설정 · 실거래',
|
||||
|
||||
@@ -24,6 +24,7 @@ export function isTradingMenuPage(page: string): boolean {
|
||||
|
||||
export const TRADING_SETTINGS_CATEGORIES = new Set([
|
||||
'strategy',
|
||||
'trading',
|
||||
'paper',
|
||||
'virtual',
|
||||
'live',
|
||||
|
||||
Reference in New Issue
Block a user