feat(portfolio): link holdings into trade plans

This commit is contained in:
root 2026-05-06 18:25:35 +00:00
parent 1f03bb83cd
commit ddf55d8f26
5 changed files with 186 additions and 68 deletions

View File

@ -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' };

View File

@ -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>
)})
) : (

View File

@ -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>
);

View File

@ -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.');
});
});

View File

@ -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>
)}