투자관리 화면 수정
This commit is contained in:
@@ -104,6 +104,8 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
);
|
||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||
/** 우측 거래내역 목록에서 선택한 체결 행 */
|
||||
const [selectedTradeId, setSelectedTradeId] = useState<number | null>(null);
|
||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -185,10 +187,14 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
return coerceFiniteNumber(p?.quantity) ?? 0;
|
||||
}, [s?.positions, selectedMarket]);
|
||||
|
||||
const investmentTargets = useMemo(
|
||||
() => s?.allocations ?? [],
|
||||
[s?.allocations],
|
||||
);
|
||||
/** 가상매매 투자대상 순서·종목과 일치 (allocations는 sync 부가 정보) */
|
||||
const investmentTargets = useMemo(() => {
|
||||
const allocs = s?.allocations ?? [];
|
||||
const bySymbol = new Map(allocs.map(a => [a.symbol, a]));
|
||||
return virtualTargets
|
||||
.map(t => bySymbol.get(t.market))
|
||||
.filter((a): a is NonNullable<typeof a> => a != null);
|
||||
}, [s?.allocations, virtualTargets]);
|
||||
|
||||
const selectedAlloc = useMemo(
|
||||
() => investmentTargets.find(a => a.symbol === selectedMarket),
|
||||
@@ -236,17 +242,29 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
openingPrice: selectedTicker.openingPrice,
|
||||
} : undefined;
|
||||
|
||||
const handleSelectMarket = useCallback((market: string) => {
|
||||
const applyMarketAndOrderPrice = useCallback((market: string, price?: number | null) => {
|
||||
setSelectedMarket(market);
|
||||
const price = tickers?.get(market)?.tradePrice;
|
||||
if (price != null && price > 0) {
|
||||
const fillPrice = price != null && price > 0 ? price : tickers?.get(market)?.tradePrice;
|
||||
if (fillPrice != null && fillPrice > 0) {
|
||||
const seq = Date.now();
|
||||
setFillBuy({ market, price, side: 'buy', seq });
|
||||
setFillSell({ market, price, side: 'sell', seq: seq + 1 });
|
||||
setFillBuy({ market, price: fillPrice, side: 'buy', seq });
|
||||
setFillSell({ market, price: fillPrice, side: 'sell', seq: seq + 1 });
|
||||
}
|
||||
setRightTab('trade');
|
||||
}, [tickers]);
|
||||
|
||||
const handleSelectMarket = useCallback((market: string) => {
|
||||
setSelectedTradeId(null);
|
||||
applyMarketAndOrderPrice(market);
|
||||
setRightTab('trade');
|
||||
}, [applyMarketAndOrderPrice]);
|
||||
|
||||
/** 거래내역 클릭 — 차트·매매 가격만 반영, 우측 탭은 거래내역 유지 */
|
||||
const handleTradeHistorySelect = useCallback((trade: PaperTradeDto) => {
|
||||
setSelectedTradeId(trade.id);
|
||||
applyMarketAndOrderPrice(trade.symbol, trade.price);
|
||||
setCenterTab('chart');
|
||||
}, [applyMarketAndOrderPrice]);
|
||||
|
||||
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
||||
setRightTab('trade');
|
||||
const side = rowType === 'bid' ? 'buy' : 'sell';
|
||||
@@ -314,11 +332,15 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
<PaperLeftStrategyTab
|
||||
market={selectedMarket}
|
||||
summary={s}
|
||||
virtualTargets={virtualTargets}
|
||||
tickers={tickers}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onMarketSelect={handleSelectMarket}
|
||||
/>
|
||||
) : (
|
||||
<PaperLeftSettingsTab
|
||||
selectedMarket={selectedMarket}
|
||||
allocations={s?.allocations ?? []}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onSettingsSaved={() => void loadData()}
|
||||
onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }}
|
||||
@@ -441,7 +463,8 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
trades={trades}
|
||||
strategyNames={strategyNames}
|
||||
tickers={tickers}
|
||||
onSelectMarket={handleSelectMarket}
|
||||
selectedTradeId={selectedTradeId}
|
||||
onSelectTrade={handleTradeHistorySelect}
|
||||
emptyText="체결 내역이 없습니다."
|
||||
className="ptd-trade-history--fill"
|
||||
/>
|
||||
|
||||
@@ -115,6 +115,14 @@ const PaperAllocationTable: React.FC<Props> = ({
|
||||
{ret >= 0 ? '+' : ''}{ret.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
{(row.buyStage1Krw || row.buyStage2Krw || row.sellStage1Krw) ? (
|
||||
<div className="vtd-target-strategy-field ptd-alloc-stage-badge">
|
||||
<span className="vtd-target-strategy-label">분할</span>
|
||||
<span className="vtd-trade-meta-value">
|
||||
매수 {row.buyStageDone ?? 0}/3 · 매도 {row.sellStageDone ?? 0}/3
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||
import { resetPaperAccount } from '../../utils/backendApi';
|
||||
import { resetPaperAccount, type PaperAllocationDto } from '../../utils/backendApi';
|
||||
import PaperSplitPanel from './PaperSplitPanel';
|
||||
import PaperSymbolStageSettings from './PaperSymbolStageSettings';
|
||||
|
||||
interface Props {
|
||||
selectedMarket?: string;
|
||||
allocations?: PaperAllocationDto[];
|
||||
onSettingsSaved?: () => void;
|
||||
onAccountReset?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
@@ -16,8 +19,18 @@ const SettingsFieldRow: React.FC<{ label: string; children: React.ReactNode }> =
|
||||
</div>
|
||||
);
|
||||
|
||||
const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset, onOpenSettings }) => {
|
||||
const PaperLeftSettingsTab: React.FC<Props> = ({
|
||||
selectedMarket,
|
||||
allocations = [],
|
||||
onSettingsSaved,
|
||||
onAccountReset,
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
const { defaults, save, isLoaded } = useAppSettings();
|
||||
const selectedAlloc = useMemo(
|
||||
() => allocations.find(a => a.symbol === selectedMarket),
|
||||
[allocations, selectedMarket],
|
||||
);
|
||||
|
||||
if (!isLoaded) {
|
||||
return <p className="ptd-muted">설정 로딩…</p>;
|
||||
@@ -78,7 +91,17 @@ const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset
|
||||
);
|
||||
|
||||
const bottom = (
|
||||
<div className="ptd-settings-panel ptd-settings-panel--account">
|
||||
<div className="ptd-settings-panel ptd-settings-panel--account ptd-settings-panel--stages">
|
||||
{selectedMarket ? (
|
||||
<PaperSymbolStageSettings
|
||||
symbol={selectedMarket}
|
||||
allocation={selectedAlloc}
|
||||
onSaved={onSettingsSaved}
|
||||
/>
|
||||
) : (
|
||||
<p className="ptd-muted ptd-item-card-empty">중앙 투자금 관리에서 종목을 선택하면 분할 매매 단계를 설정할 수 있습니다.</p>
|
||||
)}
|
||||
<div className="ptd-settings-panel-divider" aria-hidden />
|
||||
<div className="vtd-target-item ptd-item-card ptd-settings-card">
|
||||
<div className="ptd-item-card-metrics">
|
||||
<SettingsFieldRow label="초기 자본 (KRW)">
|
||||
@@ -135,7 +158,7 @@ const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset
|
||||
<PaperSplitPanel
|
||||
className="ptd-split-panel--left"
|
||||
topTitle="운영 · 자동매매"
|
||||
bottomTitle="계좌 · 비용"
|
||||
bottomTitle="종목별 분할 매매 · 계좌"
|
||||
top={top}
|
||||
bottom={bottom}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
loadStrategies,
|
||||
loadLiveStrategySettings,
|
||||
@@ -6,13 +6,18 @@ import {
|
||||
type PaperSummaryDto,
|
||||
type StrategyDto,
|
||||
} from '../../utils/backendApi';
|
||||
import { loadVirtualSession } from '../../utils/virtualTradingStorage';
|
||||
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;
|
||||
}
|
||||
@@ -20,6 +25,8 @@ interface Props {
|
||||
const PaperLeftStrategyTab: React.FC<Props> = ({
|
||||
market,
|
||||
summary,
|
||||
virtualTargets = [],
|
||||
tickers,
|
||||
paperAutoTradeEnabled = false,
|
||||
onMarketSelect,
|
||||
}) => {
|
||||
@@ -91,7 +98,23 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
const positions = summary?.positions ?? [];
|
||||
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">
|
||||
@@ -99,14 +122,21 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
||||
<span>보유 목록</span>
|
||||
<span className="vtd-target-count">{positions.length}종목</span>
|
||||
</div>
|
||||
{positions.length === 0 ? (
|
||||
<p className="ptd-muted ptd-item-card-empty">보유 종목이 없습니다.</p>
|
||||
{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 code = p.symbol.replace(/^KRW-/, '');
|
||||
const name = p.koreanName || code;
|
||||
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;
|
||||
@@ -132,8 +162,8 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
||||
>
|
||||
<div className="vtd-target-item-main">
|
||||
<div className="vtd-target-names">
|
||||
<span className="vtd-target-ko">{name}</span>
|
||||
<span className="vtd-target-en">{code}/KRW</span>
|
||||
<span className="vtd-target-ko">{ko}</span>
|
||||
<span className="vtd-target-en">{en}</span>
|
||||
</div>
|
||||
{p.profitLoss != null && (
|
||||
<div className="vtd-target-item-actions">
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import type { PaperAllocationDto } from '../../utils/backendApi';
|
||||
import { patchPaperAllocation } from '../../utils/backendApi';
|
||||
import { fmtKrw } from '../TradeOrderPanel';
|
||||
|
||||
interface Props {
|
||||
symbol: string;
|
||||
allocation?: PaperAllocationDto | null;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
type StageForm = {
|
||||
buy1: string;
|
||||
buy2: string;
|
||||
buy3: string;
|
||||
sell1: string;
|
||||
sell2: string;
|
||||
sell3: string;
|
||||
};
|
||||
|
||||
function toForm(row?: PaperAllocationDto | null): StageForm {
|
||||
const n = (v?: number) => (v != null && v > 0 ? String(Math.round(v)) : '');
|
||||
return {
|
||||
buy1: n(row?.buyStage1Krw),
|
||||
buy2: n(row?.buyStage2Krw),
|
||||
buy3: n(row?.buyStage3Krw),
|
||||
sell1: n(row?.sellStage1Krw),
|
||||
sell2: n(row?.sellStage2Krw),
|
||||
sell3: n(row?.sellStage3Krw),
|
||||
};
|
||||
}
|
||||
|
||||
function parseKrw(s: string): number {
|
||||
const v = Number(String(s).replace(/,/g, '').trim());
|
||||
return Number.isFinite(v) && v > 0 ? Math.round(v) : 0;
|
||||
}
|
||||
|
||||
const PaperSymbolStageSettings: React.FC<Props> = ({ symbol, allocation, onSaved }) => {
|
||||
const [form, setForm] = useState<StageForm>(() => toForm(allocation));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setForm(toForm(allocation));
|
||||
setMsg(null);
|
||||
}, [symbol, allocation?.id, allocation?.buyStage1Krw, allocation?.sellStage1Krw]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await patchPaperAllocation(symbol, {
|
||||
buyStage1Krw: parseKrw(form.buy1),
|
||||
buyStage2Krw: parseKrw(form.buy2),
|
||||
buyStage3Krw: parseKrw(form.buy3),
|
||||
sellStage1Krw: parseKrw(form.sell1),
|
||||
sellStage2Krw: parseKrw(form.sell2),
|
||||
sellStage3Krw: parseKrw(form.sell3),
|
||||
});
|
||||
setMsg('저장되었습니다.');
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '저장 실패');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [symbol, form, onSaved]);
|
||||
|
||||
const name = allocation?.koreanName || symbol.replace(/^KRW-/, '');
|
||||
const buyDone = allocation?.buyStageDone ?? 0;
|
||||
const sellDone = allocation?.sellStageDone ?? 0;
|
||||
|
||||
return (
|
||||
<div className="vtd-target-item ptd-item-card ptd-settings-card ptd-stage-settings">
|
||||
<div className="ptd-stage-settings-head">
|
||||
<span className="vtd-target-ko">{name}</span>
|
||||
<span className="vtd-target-en">{symbol}</span>
|
||||
</div>
|
||||
<p className="ptd-muted ptd-stage-settings-hint">
|
||||
자동매매 시그널마다 다음 단계 금액만 체결합니다. 매수·매도 금액을 0으로 두면 해당 차수는 건너뜁니다.
|
||||
</p>
|
||||
<div className="ptd-stage-progress">
|
||||
<span>진행: 매수 {buyDone}/3 · 매도 {sellDone}/3</span>
|
||||
{(buyDone > 0 || sellDone > 0) && (
|
||||
<span className="ptd-muted"> (전량 매도 시 매수·매도 단계 초기화)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ptd-stage-grid">
|
||||
<div className="ptd-stage-col">
|
||||
<span className="ptd-stage-col-title">매수 단계 (KRW)</span>
|
||||
{(['buy1', 'buy2', 'buy3'] as const).map((key, i) => (
|
||||
<label key={key} className="vtd-target-strategy-field ptd-stage-field">
|
||||
<span className="vtd-target-strategy-label">{i + 1}차 매수</span>
|
||||
<input
|
||||
type="number"
|
||||
className="vtd-trade-meta-value ptd-settings-input"
|
||||
min={0}
|
||||
step={10000}
|
||||
value={form[key]}
|
||||
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="ptd-stage-col">
|
||||
<span className="ptd-stage-col-title">매도 단계 (KRW)</span>
|
||||
{(['sell1', 'sell2', 'sell3'] as const).map((key, i) => (
|
||||
<label key={key} className="vtd-target-strategy-field ptd-stage-field">
|
||||
<span className="vtd-target-strategy-label">{i + 1}차 매도</span>
|
||||
<input
|
||||
type="number"
|
||||
className="vtd-trade-meta-value ptd-settings-input"
|
||||
min={0}
|
||||
step={10000}
|
||||
value={form[key]}
|
||||
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{allocation?.maxInvestKrw != null && allocation.maxInvestKrw > 0 && (
|
||||
<p className="ptd-muted ptd-stage-sum">
|
||||
매수 합계 {fmtKrw(
|
||||
parseKrw(form.buy1) + parseKrw(form.buy2) + parseKrw(form.buy3),
|
||||
)} · 종목 한도 {fmtKrw(allocation.maxInvestKrw)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="bps-btn bps-btn--primary ptd-stage-save"
|
||||
disabled={saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? '저장 중…' : '단계 금액 저장'}
|
||||
</button>
|
||||
{msg && <p className={`ptd-stage-msg${msg.includes('실패') ? ' ptd-stage-msg--err' : ''}`}>{msg}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaperSymbolStageSettings;
|
||||
@@ -27,6 +27,10 @@ interface Props {
|
||||
tickers?: Map<string, TickerData>;
|
||||
emptyText?: string;
|
||||
className?: string;
|
||||
selectedTradeId?: number | null;
|
||||
/** 투자관리 — 체결 행 전체 전달(탭 유지·선택 표시) */
|
||||
onSelectTrade?: (trade: PaperTradeDto) => void;
|
||||
/** 가상투자 등 — 종목만 (기존 동작) */
|
||||
onSelectMarket?: (market: string) => void;
|
||||
}
|
||||
|
||||
@@ -36,6 +40,8 @@ const PaperTradeHistoryList: React.FC<Props> = ({
|
||||
tickers,
|
||||
emptyText = '거래 내역이 없습니다.',
|
||||
className = '',
|
||||
selectedTradeId = null,
|
||||
onSelectTrade,
|
||||
onSelectMarket,
|
||||
}) => (
|
||||
<div className={`ptd-trade-history vtd-trade-history${className ? ` ${className}` : ''}`}>
|
||||
@@ -59,7 +65,12 @@ const PaperTradeHistoryList: React.FC<Props> = ({
|
||||
? (strategyNames[t.strategyId] ?? `#${t.strategyId}`)
|
||||
: '—';
|
||||
const isBuy = t.side === 'BUY';
|
||||
const clickable = Boolean(onSelectMarket);
|
||||
const clickable = Boolean(onSelectTrade || onSelectMarket);
|
||||
const selected = selectedTradeId != null && selectedTradeId === t.id;
|
||||
const handleActivate = () => {
|
||||
if (onSelectTrade) onSelectTrade(t);
|
||||
else onSelectMarket?.(t.symbol);
|
||||
};
|
||||
const sideCls = isBuy ? 'vtd-trade-item--buy' : 'vtd-trade-item--sell';
|
||||
const colorCls = isBuy ? 'vtd-target-up' : 'vtd-target-dn';
|
||||
|
||||
@@ -71,14 +82,16 @@ const PaperTradeHistoryList: React.FC<Props> = ({
|
||||
'vtd-trade-item',
|
||||
sideCls,
|
||||
clickable ? 'vtd-trade-item--click' : '',
|
||||
selected ? 'vtd-trade-item--selected' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={clickable ? () => onSelectMarket!(t.symbol) : undefined}
|
||||
aria-pressed={clickable ? selected : undefined}
|
||||
onClick={clickable ? handleActivate : undefined}
|
||||
onKeyDown={clickable ? e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelectMarket!(t.symbol);
|
||||
handleActivate();
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user