222 lines
8.6 KiB
TypeScript
222 lines
8.6 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
loadStrategies,
|
|
loadLiveStrategySettings,
|
|
type LiveStrategySettingsDto,
|
|
type PaperSummaryDto,
|
|
type StrategyDto,
|
|
} from '../../utils/backendApi';
|
|
import { loadVirtualSession, type VirtualTargetItem } from '../../utils/virtualTradingStorage';
|
|
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
|
|
import type { TickerData } from '../../hooks/useMarketTicker';
|
|
import { fmtKrw } from '../TradeOrderPanel';
|
|
import PaperSplitPanel from './PaperSplitPanel';
|
|
|
|
interface Props {
|
|
market: string;
|
|
summary: PaperSummaryDto | null;
|
|
/** 가상매매 투자대상 — 보유 목록은 이 종목만 표시 */
|
|
virtualTargets?: VirtualTargetItem[];
|
|
tickers?: Map<string, TickerData>;
|
|
paperAutoTradeEnabled?: boolean;
|
|
onMarketSelect?: (market: string) => void;
|
|
}
|
|
|
|
const PaperLeftStrategyTab: React.FC<Props> = ({
|
|
market,
|
|
summary,
|
|
virtualTargets = [],
|
|
tickers,
|
|
paperAutoTradeEnabled = false,
|
|
onMarketSelect,
|
|
}) => {
|
|
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
|
const [settings, setSettings] = useState<LiveStrategySettingsDto | null>(null);
|
|
const [virtualRunning, setVirtualRunning] = useState(() => loadVirtualSession().running);
|
|
|
|
useEffect(() => {
|
|
loadStrategies().then(setStrategies).catch(() => setStrategies([]));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const refreshVirtual = () => setVirtualRunning(loadVirtualSession().running);
|
|
window.addEventListener('gc_virtual_session_changed', refreshVirtual);
|
|
window.addEventListener('storage', refreshVirtual);
|
|
return () => {
|
|
window.removeEventListener('gc_virtual_session_changed', refreshVirtual);
|
|
window.removeEventListener('storage', refreshVirtual);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
loadLiveStrategySettings(market)
|
|
.then(s => { if (!cancelled) setSettings(s); })
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setSettings({
|
|
market,
|
|
strategyId: null,
|
|
isLiveCheck: false,
|
|
executionType: 'CANDLE_CLOSE',
|
|
positionMode: 'LONG_ONLY',
|
|
});
|
|
}
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [market, virtualRunning]);
|
|
|
|
const selected = strategies.find(s => s.id === settings?.strategyId);
|
|
const execLabel = settings?.executionType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후';
|
|
|
|
const top = (
|
|
<>
|
|
<div className="ptd-asset-inline">
|
|
<div className="ptd-asset-total ptd-asset-total--sm">
|
|
{summary ? fmtKrw(summary.totalAsset) : '—'} <span>KRW</span>
|
|
</div>
|
|
<div className={`ptd-asset-ret ptd-asset-ret--sm ${(summary?.totalReturnPct ?? 0) >= 0 ? 'up' : 'down'}`}>
|
|
{summary ? `${(summary.totalReturnPct ?? 0) >= 0 ? '+' : ''}${summary.totalReturnPct.toFixed(2)}%` : '—'}
|
|
</div>
|
|
</div>
|
|
<div className="ptd-left-section">
|
|
<p className="ptd-left-hint">
|
|
실시간 전략·체크 대상 종목은 <strong>가상투자</strong> 화면에서 설정합니다.
|
|
{virtualRunning ? ' (세션 실행 중)' : ''}
|
|
</p>
|
|
{settings?.isLiveCheck && selected && (
|
|
<p className="ptd-left-hint">
|
|
{market.replace(/^KRW-/, '')}: {selected.name} · {execLabel}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<p className={`ptd-left-status ${paperAutoTradeEnabled ? 'ptd-left-status--on' : ''}`}>
|
|
{paperAutoTradeEnabled
|
|
? '자동매매 ON — 가상 Match 후 서버가 전략 시그널·모의 체결(화면 꺼져도 동작)'
|
|
: '자동매매 OFF — 수동 주문만 모의 계좌에 체결'}
|
|
</p>
|
|
</>
|
|
);
|
|
|
|
const targetMarkets = useMemo(
|
|
() => new Set(virtualTargets.map(t => t.market)),
|
|
[virtualTargets],
|
|
);
|
|
|
|
const positions = useMemo(() => {
|
|
const all = (summary?.positions ?? []).filter(p => p.quantity > 0);
|
|
if (targetMarkets.size === 0) return [];
|
|
const order = virtualTargets.map(t => t.market);
|
|
return all
|
|
.filter(p => targetMarkets.has(p.symbol))
|
|
.sort((a, b) => {
|
|
const ia = order.indexOf(a.symbol);
|
|
const ib = order.indexOf(b.symbol);
|
|
return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
|
|
});
|
|
}, [summary?.positions, targetMarkets, virtualTargets]);
|
|
|
|
const bottom = (
|
|
<div className="ptd-item-card-section ptd-holdings-card-section">
|
|
<div className="vtd-target-list-head">
|
|
<span>보유 목록</span>
|
|
<span className="vtd-target-count">{positions.length}종목</span>
|
|
</div>
|
|
{targetMarkets.size === 0 ? (
|
|
<p className="ptd-muted ptd-item-card-empty">가상매매 투자대상이 없습니다. 가상투자에서 종목을 추가하세요.</p>
|
|
) : positions.length === 0 ? (
|
|
<p className="ptd-muted ptd-item-card-empty">투자대상 종목 중 보유 수량이 있는 종목이 없습니다.</p>
|
|
) : (
|
|
<div className="ptd-item-card-scroll">
|
|
<div className="vtd-target-list ptd-item-card-list">
|
|
{positions.map(p => {
|
|
const ticker = tickers?.get(p.symbol);
|
|
const vt = virtualTargets.find(t => t.market === p.symbol);
|
|
const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(
|
|
p.symbol,
|
|
p.koreanName ?? vt?.koreanName ?? ticker?.koreanName,
|
|
vt?.englishName,
|
|
);
|
|
const up = (p.profitLoss ?? 0) >= 0;
|
|
const selected = market === p.symbol;
|
|
const retPct = p.profitLossPct;
|
|
|
|
return (
|
|
<div
|
|
key={p.symbol}
|
|
className={[
|
|
'vtd-target-item',
|
|
'ptd-item-card',
|
|
'ptd-holding-card',
|
|
selected ? 'vtd-target-item--active' : '',
|
|
].filter(Boolean).join(' ')}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => onMarketSelect?.(p.symbol)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onMarketSelect?.(p.symbol);
|
|
}
|
|
}}
|
|
>
|
|
<div className="vtd-target-item-main">
|
|
<div className="vtd-target-names">
|
|
<span className="vtd-target-ko">{ko}</span>
|
|
<span className="vtd-target-en">{en}</span>
|
|
</div>
|
|
{p.profitLoss != null && (
|
|
<div className="vtd-target-item-actions">
|
|
<span className={`vtd-target-price ${up ? 'vtd-target-up' : 'vtd-target-dn'}`}>
|
|
{up ? '+' : ''}{fmtKrw(p.profitLoss)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="ptd-item-card-metrics">
|
|
<div className="vtd-target-strategy-field">
|
|
<span className="vtd-target-strategy-label">보유수량</span>
|
|
<span className="vtd-trade-meta-value">{p.quantity.toFixed(4)}</span>
|
|
</div>
|
|
<div className="vtd-target-strategy-field">
|
|
<span className="vtd-target-strategy-label">평단가</span>
|
|
<span className="vtd-trade-meta-value">{fmtKrw(p.avgPrice)}</span>
|
|
</div>
|
|
{p.evalAmount != null && (
|
|
<div className="vtd-target-strategy-field">
|
|
<span className="vtd-target-strategy-label">평가금액</span>
|
|
<span className="vtd-trade-meta-value">{fmtKrw(p.evalAmount)}</span>
|
|
</div>
|
|
)}
|
|
{retPct != null && (
|
|
<div className="vtd-target-strategy-field">
|
|
<span className="vtd-target-strategy-label">수익률</span>
|
|
<span className={`vtd-trade-meta-value ${retPct >= 0 ? 'vtd-target-up' : 'vtd-target-dn'}`}>
|
|
{retPct >= 0 ? '+' : ''}{retPct.toFixed(2)}%
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<PaperSplitPanel
|
|
className="ptd-split-panel--left"
|
|
topTitle="전략 · 자산"
|
|
bottomTitle="보유 종목"
|
|
top={top}
|
|
bottom={bottom}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default PaperLeftStrategyTab;
|