feat(portfolio): link holdings into trade plans
This commit is contained in:
parent
1f03bb83cd
commit
ddf55d8f26
@ -350,6 +350,45 @@ describe('PositionsTab DOM behavior', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes a manage-in-plans action for eligible live bot holdings', async () => {
|
||||
const now = Date.now();
|
||||
fetchPositionsBootstrapMock.mockResolvedValue({
|
||||
entries: [],
|
||||
orders: [{
|
||||
id: 'entry-order',
|
||||
order_id: 'entry-order',
|
||||
profile_id: 'p1',
|
||||
symbol: 'BTC/USDT',
|
||||
type: 'Market',
|
||||
side: 'BUY',
|
||||
qty: 1,
|
||||
price: 100,
|
||||
status: 'filled',
|
||||
timestamp: now - 1_000,
|
||||
created_at: new Date(now - 1_000).toISOString(),
|
||||
trade_id: 'TRD-POS-1',
|
||||
action: 'ENTRY',
|
||||
source: 'BOT'
|
||||
}],
|
||||
historyTradeKeys: [],
|
||||
profiles: [{ id: 'p1', name: 'High Risk Scalper' }]
|
||||
});
|
||||
|
||||
const onManageHolding = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<PositionsTab botState={buildBotState(now)} onManageHolding={onManageHolding} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' }));
|
||||
const manageButtons = await screen.findAllByRole('button', { name: 'Manage in Plans' });
|
||||
await user.click(manageButtons[0]);
|
||||
|
||||
expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({
|
||||
symbol: 'BTC/USDT',
|
||||
profileId: 'p1',
|
||||
tradeId: 'TRD-POS-1'
|
||||
}));
|
||||
});
|
||||
|
||||
it('handles non-admin bootstrap failures with empty-state fallback', async () => {
|
||||
authState.user = { id: 'user-2' };
|
||||
authState.profile = { role: 'trader' };
|
||||
|
||||
@ -8,9 +8,10 @@ import { Layers, ListFilter, Link2, GitBranch, AlertTriangle, Lock, RefreshCw, C
|
||||
import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle';
|
||||
import { fetchPositionsBootstrap } from '../lib/positionsApi';
|
||||
|
||||
interface PositionsTabProps {
|
||||
botState: BotState;
|
||||
}
|
||||
interface PositionsTabProps {
|
||||
botState: BotState;
|
||||
onManageHolding?: (position: HybridPosition) => void;
|
||||
}
|
||||
|
||||
interface HybridPosition {
|
||||
source: 'BOT' | 'MANUAL';
|
||||
@ -414,7 +415,7 @@ export const assignLifecycleTradeIds = (
|
||||
return enriched;
|
||||
};
|
||||
|
||||
export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
||||
export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) => {
|
||||
const { user, profile } = useAuth();
|
||||
const [manualPositions, setManualPositions] = useState<HybridPosition[]>([]);
|
||||
const [simplePlanMetaByTradeId, setSimplePlanMetaByTradeId] = useState<Record<string, { holdingMode: 'short_term' | 'long_term'; automationState?: string | null }>>({});
|
||||
@ -1433,41 +1434,51 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
||||
<div className="text-xs font-mono text-gray-500">-</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
{pos.source === 'BOT' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Are you sure you want to CLOSE ${pos.symbol}?`)) return;
|
||||
try {
|
||||
const accessToken = await getPlatformAccessToken();
|
||||
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/close`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'x-request-id': createRequestId('web-close')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
profile_id: pos.profileId,
|
||||
symbol: pos.symbol
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert(`Successfully closed ${pos.symbol}`);
|
||||
} else {
|
||||
alert(`Failed to close: ${data.error}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(`Error: ${e.message}`);
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 rounded text-[10px] font-bold uppercase transition-colors"
|
||||
>
|
||||
Square Off
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{pos.source === 'BOT' && pos.profileId && pos.tradeId && onManageHolding && (
|
||||
<button
|
||||
onClick={() => onManageHolding(pos)}
|
||||
className="px-2 py-1 bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/20 rounded text-[10px] font-bold uppercase transition-colors"
|
||||
>
|
||||
Manage in Plans
|
||||
</button>
|
||||
)}
|
||||
{pos.source === 'BOT' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Are you sure you want to CLOSE ${pos.symbol}?`)) return;
|
||||
try {
|
||||
const accessToken = await getPlatformAccessToken();
|
||||
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/close`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'x-request-id': createRequestId('web-close')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
profile_id: pos.profileId,
|
||||
symbol: pos.symbol
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert(`Successfully closed ${pos.symbol}`);
|
||||
} else {
|
||||
alert(`Failed to close: ${data.error}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(`Error: ${e.message}`);
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 rounded text-[10px] font-bold uppercase transition-colors"
|
||||
>
|
||||
Square Off
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)})
|
||||
) : (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { PositionsTab } from '../tabs/PositionsTab';
|
||||
import { HistoryTab } from '../tabs/HistoryTab';
|
||||
@ -9,6 +10,7 @@ type Tab = typeof TABS[number];
|
||||
|
||||
export function PortfolioView() {
|
||||
const { botState } = useAppContext();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<Tab>('Positions & Orders');
|
||||
|
||||
return (
|
||||
@ -31,7 +33,19 @@ export function PortfolioView() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'Positions & Orders' && <PositionsTab botState={botState} />}
|
||||
{tab === 'Positions & Orders' && (
|
||||
<PositionsTab
|
||||
botState={botState}
|
||||
onManageHolding={(position) => {
|
||||
const params = new URLSearchParams({
|
||||
mode: 'sell',
|
||||
symbol: position.symbol,
|
||||
});
|
||||
if (position.tradeId) params.set('tradeId', position.tradeId);
|
||||
navigate(`/simple?${params.toString()}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tab === 'Trade History' && <HistoryTab botState={botState} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -205,6 +205,6 @@ describe('SimpleView helpers', () => {
|
||||
profitValue: '6',
|
||||
notes: '',
|
||||
},
|
||||
})).toThrow('Sell setups require an existing Simple holding for this symbol.');
|
||||
})).toThrow('Sell setups require an existing holding for this symbol.');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Pencil, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { fetchChartBars, fetchResearchProfile } from '../lib/marketApi';
|
||||
import {
|
||||
@ -201,7 +202,7 @@ export function buildSimpleSetupPayload(input: {
|
||||
const automationState = normalizeAutomationState(existingEntry?.automation_state, existingEntry || { status: side === 'buy' ? 'simple_armed_buy' : 'simple_armed_sell' } as ManualEntryPayload);
|
||||
|
||||
if (side === 'sell' && !holding) {
|
||||
throw new Error('Sell setups require an existing Simple holding for this symbol.');
|
||||
throw new Error('Sell setups require an existing holding for this symbol.');
|
||||
}
|
||||
|
||||
if (side === 'buy' && parseNonNegativeNumber(input.draft.dropValue) === null) {
|
||||
@ -280,7 +281,7 @@ function buildPreviewText(draft: SimpleSetupDraft, holding: SimpleHolding | null
|
||||
}
|
||||
|
||||
if (!holding) {
|
||||
return `Sell ${symbol} only works when a Simple holding already exists.`;
|
||||
return `Sell ${symbol} only works when an eligible existing holding is available.`;
|
||||
}
|
||||
|
||||
const profitTargetPrice = computeProfitTargetPrice(holding.entryPrice, draft.profitMode, draft.profitValue);
|
||||
@ -616,6 +617,7 @@ function describeSavedSetup(entry: ManualEntryPayload): string {
|
||||
|
||||
export function SimpleView() {
|
||||
const { botState } = useAppContext();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [profiles, setProfiles] = useState<TradeProfilePayload[]>([]);
|
||||
const [savedSetups, setSavedSetups] = useState<ManualEntryPayload[]>([]);
|
||||
const [editingSetupId, setEditingSetupId] = useState<string | null>(null);
|
||||
@ -626,7 +628,9 @@ export function SimpleView() {
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedHoldingTradeId, setSelectedHoldingTradeId] = useState<string | null>(null);
|
||||
const marketPriceRequestSymbolRef = useRef<string>('');
|
||||
const consumedPrefillRef = useRef(false);
|
||||
|
||||
const normalizedSymbol = draft.symbol.trim().toUpperCase();
|
||||
const symbolState = botState?.symbols && typeof botState.symbols === 'object' ? botState.symbols : {};
|
||||
@ -651,6 +655,24 @@ export function SimpleView() {
|
||||
.filter((position) => position.symbol && position.size > 0 && position.entryPrice > 0);
|
||||
}, [botState?.positions, simpleAutoProfile?.id]);
|
||||
|
||||
const availableSellHoldings = useMemo(() => {
|
||||
const positions = normalizeRuntimeArray<typeof botState.positions[number]>(botState?.positions);
|
||||
return positions
|
||||
.map((position) => ({
|
||||
symbol: String(position.symbol || '').trim().toUpperCase(),
|
||||
size: Number(position.size || 0),
|
||||
entryPrice: Number(position.entryPrice || 0),
|
||||
profileId: position.profileId,
|
||||
tradeId: position.tradeId,
|
||||
}))
|
||||
.filter((position) => position.symbol && position.size > 0 && position.entryPrice > 0 && position.profileId && position.tradeId)
|
||||
.sort((left, right) => {
|
||||
const bySymbol = left.symbol.localeCompare(right.symbol);
|
||||
if (bySymbol !== 0) return bySymbol;
|
||||
return String(left.tradeId || '').localeCompare(String(right.tradeId || ''));
|
||||
});
|
||||
}, [botState?.positions]);
|
||||
|
||||
const runtimeOrders = useMemo(() => {
|
||||
const orders = normalizeRuntimeArray<Record<string, any>>(botState?.orders);
|
||||
return orders.filter((order) => {
|
||||
@ -663,21 +685,19 @@ export function SimpleView() {
|
||||
[botState?.operationalEvents],
|
||||
);
|
||||
|
||||
const matchingHolding = useMemo(
|
||||
() => simpleHoldings.find((holding) => holding.symbol === normalizedSymbol) || null,
|
||||
[simpleHoldings, normalizedSymbol],
|
||||
);
|
||||
const availableSellHoldings = useMemo(
|
||||
() => [...simpleHoldings].sort((left, right) => left.symbol.localeCompare(right.symbol)),
|
||||
[simpleHoldings],
|
||||
);
|
||||
const selectedSellHolding = useMemo(() => {
|
||||
if (selectedHoldingTradeId) {
|
||||
return availableSellHoldings.find((holding) => holding.tradeId === selectedHoldingTradeId) || null;
|
||||
}
|
||||
return availableSellHoldings.find((holding) => holding.symbol === normalizedSymbol) || null;
|
||||
}, [availableSellHoldings, normalizedSymbol, selectedHoldingTradeId]);
|
||||
|
||||
const supportedSymbols = useMemo(() => {
|
||||
const fromState = Object.keys(symbolState || {}).map(normalizeKnownSymbol).filter(Boolean) as string[];
|
||||
const fromSetups = savedSetups.map((entry) => normalizeKnownSymbol(entry.symbol)).filter(Boolean) as string[];
|
||||
const fromHoldings = simpleHoldings.map((holding) => normalizeKnownSymbol(holding.symbol)).filter(Boolean) as string[];
|
||||
const fromHoldings = availableSellHoldings.map((holding) => normalizeKnownSymbol(holding.symbol)).filter(Boolean) as string[];
|
||||
return Array.from(new Set([...fromState, ...fromSetups, ...fromHoldings, ...COMMON_SIMPLE_SYMBOLS])).sort((left, right) => left.localeCompare(right));
|
||||
}, [symbolState, savedSetups, simpleHoldings]);
|
||||
}, [symbolState, savedSetups, availableSellHoldings]);
|
||||
|
||||
const filteredSymbolSuggestions = useMemo(() => {
|
||||
if (!normalizedSymbol) {
|
||||
@ -747,16 +767,23 @@ export function SimpleView() {
|
||||
}, [normalizedSymbol, livePrice, draft.currentMarketPrice, loadingPrice, supportedSymbols]);
|
||||
|
||||
const previewText = useMemo(
|
||||
() => buildPreviewText(draft, draft.side === 'sell' ? matchingHolding : null),
|
||||
[draft, matchingHolding],
|
||||
() => buildPreviewText(draft, draft.side === 'sell' ? selectedSellHolding : null),
|
||||
[draft, selectedSellHolding],
|
||||
);
|
||||
|
||||
function updateDraft<K extends keyof SimpleSetupDraft>(key: K, value: SimpleSetupDraft[K]) {
|
||||
if (key === 'side' && value === 'buy') {
|
||||
setSelectedHoldingTradeId(null);
|
||||
}
|
||||
if (key === 'symbol' && draft.side === 'sell') {
|
||||
setSelectedHoldingTradeId(null);
|
||||
}
|
||||
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function applyHoldingToDraft(holding: SimpleHolding) {
|
||||
setMarketPriceSource(null);
|
||||
setSelectedHoldingTradeId(holding.tradeId || null);
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
side: 'sell',
|
||||
@ -768,10 +795,35 @@ export function SimpleView() {
|
||||
|
||||
useEffect(() => {
|
||||
if (draft.side !== 'sell') return;
|
||||
if (matchingHolding) return;
|
||||
if (selectedSellHolding) return;
|
||||
if (availableSellHoldings.length === 0) return;
|
||||
applyHoldingToDraft(availableSellHoldings[0]);
|
||||
}, [draft.side, matchingHolding, availableSellHoldings]);
|
||||
}, [draft.side, selectedSellHolding, availableSellHoldings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (consumedPrefillRef.current) return;
|
||||
const requestedMode = String(searchParams.get('mode') || '').trim().toLowerCase();
|
||||
const requestedSymbol = String(searchParams.get('symbol') || '').trim().toUpperCase();
|
||||
const requestedTradeId = String(searchParams.get('tradeId') || '').trim();
|
||||
if (requestedMode !== 'sell') return;
|
||||
if (availableSellHoldings.length === 0) return;
|
||||
|
||||
const selected = (requestedTradeId
|
||||
? availableSellHoldings.find((holding) => holding.tradeId === requestedTradeId)
|
||||
: null)
|
||||
|| (requestedSymbol
|
||||
? availableSellHoldings.find((holding) => holding.symbol === requestedSymbol)
|
||||
: null)
|
||||
|| availableSellHoldings[0];
|
||||
|
||||
if (selected) {
|
||||
applyHoldingToDraft(selected);
|
||||
setMessage(`Loaded ${selected.symbol} from Portfolio. Configure the profit target and save the plan.`);
|
||||
setError(null);
|
||||
}
|
||||
consumedPrefillRef.current = true;
|
||||
setSearchParams({}, { replace: true });
|
||||
}, [availableSellHoldings, searchParams, setSearchParams]);
|
||||
|
||||
async function copyIdentifier(kind: 'trade' | 'order', value: string | null | undefined) {
|
||||
if (!value) return;
|
||||
@ -867,7 +919,7 @@ export function SimpleView() {
|
||||
const payload = buildSimpleSetupPayload({
|
||||
draft,
|
||||
existingId: editingSetupId || undefined,
|
||||
holding: draft.side === 'sell' ? matchingHolding : null,
|
||||
holding: draft.side === 'sell' ? selectedSellHolding : null,
|
||||
existingEntry,
|
||||
});
|
||||
|
||||
@ -958,7 +1010,7 @@ export function SimpleView() {
|
||||
}
|
||||
|
||||
const saveButtonLabel = editingSetupId ? 'Update setup' : 'Save setup';
|
||||
const saveButtonDisabled = submitting || loadingPrice || (draft.side === 'sell' && !matchingHolding);
|
||||
const saveButtonDisabled = submitting || loadingPrice || (draft.side === 'sell' && !selectedSellHolding);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@ -978,6 +1030,7 @@ export function SimpleView() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingSetupId(null);
|
||||
setSelectedHoldingTradeId(null);
|
||||
setMarketPriceSource(draft.currentMarketPrice ? marketPriceSource : null);
|
||||
setDraft({
|
||||
...DEFAULT_DRAFT,
|
||||
@ -1002,6 +1055,7 @@ export function SimpleView() {
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
setSelectedHoldingTradeId(null);
|
||||
setDraft((prev) => ({ ...prev, side: 'buy' }));
|
||||
}}
|
||||
className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${
|
||||
@ -1041,9 +1095,9 @@ export function SimpleView() {
|
||||
<label className="space-y-2">
|
||||
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Existing holding</span>
|
||||
<Select
|
||||
value={matchingHolding?.symbol || normalizedSymbol}
|
||||
value={selectedHoldingTradeId || ''}
|
||||
onChange={(e) => {
|
||||
const selected = availableSellHoldings.find((holding) => holding.symbol === e.target.value);
|
||||
const selected = availableSellHoldings.find((holding) => (holding.tradeId || '') === e.target.value);
|
||||
if (selected) applyHoldingToDraft(selected);
|
||||
}}
|
||||
disabled={availableSellHoldings.length === 0}
|
||||
@ -1052,7 +1106,7 @@ export function SimpleView() {
|
||||
<option value="">No eligible holdings available</option>
|
||||
) : (
|
||||
availableSellHoldings.map((holding) => (
|
||||
<option key={`${holding.symbol}:${holding.tradeId || 'holding'}`} value={holding.symbol}>
|
||||
<option key={`${holding.symbol}:${holding.tradeId || 'holding'}`} value={holding.tradeId || ''}>
|
||||
{holding.symbol} · {holding.size} @ {holding.entryPrice.toFixed(4)}
|
||||
</option>
|
||||
))
|
||||
@ -1215,9 +1269,9 @@ export function SimpleView() {
|
||||
Holding size
|
||||
</span>
|
||||
<Input
|
||||
value={draft.side === 'sell' && matchingHolding ? String(matchingHolding.size) : draft.quantity}
|
||||
value={draft.side === 'sell' && selectedSellHolding ? String(selectedSellHolding.size) : draft.quantity}
|
||||
onChange={(e) => updateDraft('quantity', e.target.value)}
|
||||
readOnly={draft.side === 'sell' && !!matchingHolding}
|
||||
readOnly={draft.side === 'sell' && !!selectedSellHolding}
|
||||
className="read-only:bg-[var(--muted)]"
|
||||
placeholder="10"
|
||||
/>
|
||||
@ -1284,13 +1338,13 @@ export function SimpleView() {
|
||||
|
||||
{draft.side === 'sell' && (
|
||||
<div className={`rounded-[1.5rem] border px-4 py-4 text-sm ${
|
||||
matchingHolding
|
||||
selectedSellHolding
|
||||
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||
: 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||
}`}>
|
||||
{matchingHolding
|
||||
? `Simple holding ready: ${matchingHolding.symbol} · ${matchingHolding.size} units at ${matchingHolding.entryPrice.toFixed(4)}. Executed Simple buys also appear in Portfolio as live positions.`
|
||||
: 'No existing Simple holding found for this symbol. Sell setups only arm against a current Simple holding.'}
|
||||
{selectedSellHolding
|
||||
? `Holding ready: ${selectedSellHolding.symbol} · ${selectedSellHolding.size} units at ${selectedSellHolding.entryPrice.toFixed(4)}. Executed buys also appear in Portfolio as live positions.`
|
||||
: 'No eligible live holding found for this symbol yet. Managed sell setups only arm against a current holding.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user