From ddf55d8f2653d2253b10e9b0ef5e5f3f8db69eb8 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 6 May 2026 18:25:35 +0000 Subject: [PATCH] feat(portfolio): link holdings into trade plans --- web/src/tabs/PositionsTab.dom.test.tsx | 39 +++++++++ web/src/tabs/PositionsTab.tsx | 89 +++++++++++--------- web/src/views/PortfolioView.tsx | 16 +++- web/src/views/SimpleView.test.ts | 2 +- web/src/views/SimpleView.tsx | 108 ++++++++++++++++++------- 5 files changed, 186 insertions(+), 68 deletions(-) diff --git a/web/src/tabs/PositionsTab.dom.test.tsx b/web/src/tabs/PositionsTab.dom.test.tsx index bcdeba7..d2641dc 100644 --- a/web/src/tabs/PositionsTab.dom.test.tsx +++ b/web/src/tabs/PositionsTab.dom.test.tsx @@ -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(); + + 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' }; diff --git a/web/src/tabs/PositionsTab.tsx b/web/src/tabs/PositionsTab.tsx index 3c728ae..2cb153b 100644 --- a/web/src/tabs/PositionsTab.tsx +++ b/web/src/tabs/PositionsTab.tsx @@ -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([]); const [simplePlanMetaByTradeId, setSimplePlanMetaByTradeId] = useState>({}); @@ -1433,41 +1434,51 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
-
)} - - {pos.source === 'BOT' && ( - - )} - + +
+ {pos.source === 'BOT' && pos.profileId && pos.tradeId && onManageHolding && ( + + )} + {pos.source === 'BOT' && ( + + )} +
+ )}) ) : ( diff --git a/web/src/views/PortfolioView.tsx b/web/src/views/PortfolioView.tsx index f79b18f..6672e19 100644 --- a/web/src/views/PortfolioView.tsx +++ b/web/src/views/PortfolioView.tsx @@ -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('Positions & Orders'); return ( @@ -31,7 +33,19 @@ export function PortfolioView() { ))} - {tab === 'Positions & Orders' && } + {tab === 'Positions & Orders' && ( + { + const params = new URLSearchParams({ + mode: 'sell', + symbol: position.symbol, + }); + if (position.tradeId) params.set('tradeId', position.tradeId); + navigate(`/simple?${params.toString()}`); + }} + /> + )} {tab === 'Trade History' && } ); diff --git a/web/src/views/SimpleView.test.ts b/web/src/views/SimpleView.test.ts index fcf15fb..a92df99 100644 --- a/web/src/views/SimpleView.test.ts +++ b/web/src/views/SimpleView.test.ts @@ -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.'); }); }); diff --git a/web/src/views/SimpleView.tsx b/web/src/views/SimpleView.tsx index b26f651..1f84e56 100644 --- a/web/src/views/SimpleView.tsx +++ b/web/src/views/SimpleView.tsx @@ -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([]); const [savedSetups, setSavedSetups] = useState([]); const [editingSetupId, setEditingSetupId] = useState(null); @@ -626,7 +628,9 @@ export function SimpleView() { const [copiedKey, setCopiedKey] = useState(null); const [message, setMessage] = useState(null); const [error, setError] = useState(null); + const [selectedHoldingTradeId, setSelectedHoldingTradeId] = useState(null); const marketPriceRequestSymbolRef = useRef(''); + 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(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>(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(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 (
@@ -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() {