feat(portfolio): drill into saved trade plans

This commit is contained in:
root 2026-05-06 18:31:29 +00:00
parent ddf55d8f26
commit 36bd21b7cd
4 changed files with 73 additions and 14 deletions

View File

@ -350,10 +350,17 @@ describe('PositionsTab DOM behavior', () => {
});
});
it('exposes a manage-in-plans action for eligible live bot holdings', async () => {
it('exposes an open-plan action for holdings already linked to a saved plan', async () => {
const now = Date.now();
fetchPositionsBootstrapMock.mockResolvedValue({
entries: [],
entries: [{
stock_instance_id: 'simple-setup-1',
symbol: 'BTC/USDT',
workflow_type: 'simple',
linked_trade_id: 'TRD-POS-1',
holding_mode: 'short_term',
automation_state: 'holding_managed'
}],
orders: [{
id: 'entry-order',
order_id: 'entry-order',
@ -379,13 +386,14 @@ describe('PositionsTab DOM behavior', () => {
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' });
const manageButtons = await screen.findAllByRole('button', { name: /Open Plan|Manage in Plans/ });
await user.click(manageButtons[0]);
expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({
symbol: 'BTC/USDT',
profileId: 'p1',
tradeId: 'TRD-POS-1'
tradeId: 'TRD-POS-1',
planEntryId: 'simple-setup-1'
}));
});

View File

@ -30,6 +30,7 @@ interface HybridPosition {
tradeId?: string;
planMode?: 'short_term' | 'long_term';
planState?: string | null;
planEntryId?: string;
}
interface Profile {
@ -418,7 +419,7 @@ export const assignLifecycleTradeIds = (
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 }>>({});
const [simplePlanMetaByTradeId, setSimplePlanMetaByTradeId] = useState<Record<string, { entryId?: string; holdingMode: 'short_term' | 'long_term'; automationState?: string | null }>>({});
const [dbOrders, setDbOrders] = useState<RawOrderRecord[]>([]);
const [historyTradeKeys, setHistoryTradeKeys] = useState<string[]>([]);
const [profiles, setProfiles] = useState<Profile[]>([]);
@ -514,11 +515,12 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
if (!tradeId) return null;
const holdingMode = String(entry.holding_mode || '').trim().toLowerCase() === 'long_term' ? 'long_term' : 'short_term';
return [tradeId, {
entryId: String(entry.stock_instance_id || '').trim() || undefined,
holdingMode,
automationState: String(entry.automation_state || '').trim() || null,
}] as const;
})
.filter(Boolean) as Array<readonly [string, { holdingMode: 'short_term' | 'long_term'; automationState?: string | null }]>
.filter(Boolean) as Array<readonly [string, { entryId?: string; holdingMode: 'short_term' | 'long_term'; automationState?: string | null }]>
);
setSimplePlanMetaByTradeId(nextSimplePlanMetaByTradeId);
}
@ -570,6 +572,9 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
planState: p.tradeId && simplePlanMetaByTradeId[p.tradeId]
? simplePlanMetaByTradeId[p.tradeId].automationState || null
: null,
planEntryId: p.tradeId && simplePlanMetaByTradeId[p.tradeId]
? simplePlanMetaByTradeId[p.tradeId].entryId
: undefined,
};
const tradeId = String(normalized.tradeId || '').trim();
@ -1078,7 +1083,16 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
takeProfit: Number(position.takeProfit || 0) || undefined,
profileId: position.profileId,
profileName: position.profileName,
tradeId: position.tradeId
tradeId: position.tradeId,
planMode: position.tradeId && simplePlanMetaByTradeId[position.tradeId]
? simplePlanMetaByTradeId[position.tradeId].holdingMode
: undefined,
planState: position.tradeId && simplePlanMetaByTradeId[position.tradeId]
? simplePlanMetaByTradeId[position.tradeId].automationState || null
: null,
planEntryId: position.tradeId && simplePlanMetaByTradeId[position.tradeId]
? simplePlanMetaByTradeId[position.tradeId].entryId
: undefined
} as HybridPosition));
const deduped = new Map<string, HybridPosition>();
@ -1116,6 +1130,7 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
selectedProfileId,
filteredBotPositions,
filteredManualPositions,
simplePlanMetaByTradeId,
hasManagedSymbolScope,
managedSymbolTokens,
EPSILON
@ -1441,7 +1456,7 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
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
{(pos.planEntryId || pos.planMode) ? 'Open Plan' : 'Manage in Plans'}
</button>
)}
{pos.source === 'BOT' && (

View File

@ -37,11 +37,13 @@ export function PortfolioView() {
<PositionsTab
botState={botState}
onManageHolding={(position) => {
const params = new URLSearchParams({
mode: 'sell',
symbol: position.symbol,
});
if (position.tradeId) params.set('tradeId', position.tradeId);
const params = position.planEntryId
? new URLSearchParams({ setupId: position.planEntryId })
: new URLSearchParams({
mode: 'sell',
symbol: position.symbol,
});
if (!position.planEntryId && position.tradeId) params.set('tradeId', position.tradeId);
navigate(`/simple?${params.toString()}`);
}}
/>

View File

@ -629,8 +629,11 @@ export function SimpleView() {
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedHoldingTradeId, setSelectedHoldingTradeId] = useState<string | null>(null);
const [focusedSetupId, setFocusedSetupId] = useState<string | null>(null);
const marketPriceRequestSymbolRef = useRef<string>('');
const consumedPrefillRef = useRef(false);
const consumedSetupFocusRef = useRef(false);
const setupCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
const normalizedSymbol = draft.symbol.trim().toUpperCase();
const symbolState = botState?.symbols && typeof botState.symbols === 'object' ? botState.symbols : {};
@ -825,6 +828,27 @@ export function SimpleView() {
setSearchParams({}, { replace: true });
}, [availableSellHoldings, searchParams, setSearchParams]);
useEffect(() => {
if (consumedSetupFocusRef.current) return;
const requestedSetupId = String(searchParams.get('setupId') || '').trim();
if (!requestedSetupId) return;
if (savedSetups.length === 0) return;
const targetEntry = savedSetups.find((entry) => String(entry.stock_instance_id || '') === requestedSetupId) || null;
consumedSetupFocusRef.current = true;
setSearchParams({}, { replace: true });
if (!targetEntry) return;
setFocusedSetupId(requestedSetupId);
setMessage(`Focused saved plan for ${targetEntry.symbol}.`);
setError(null);
window.setTimeout(() => setFocusedSetupId((prev) => (prev === requestedSetupId ? null : prev)), 2200);
window.requestAnimationFrame(() => {
setupCardRefs.current[requestedSetupId]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}, [savedSetups, searchParams, setSearchParams]);
async function copyIdentifier(kind: 'trade' | 'order', value: string | null | undefined) {
if (!value) return;
try {
@ -1404,7 +1428,17 @@ export function SimpleView() {
const canConvertToLongTerm = side === 'buy' && holdingMode === 'short_term' && runtimeSnapshot?.stage === 'filled';
const canResumeExitManagement = side === 'buy' && holdingMode === 'long_term' && runtimeSnapshot?.stage === 'filled';
return (
<div key={entryId} className="rounded-[1.5rem] border border-[var(--border)] bg-[var(--card-elevated)] p-5">
<div
key={entryId}
ref={(node) => {
setupCardRefs.current[entryId] = node;
}}
className={`rounded-[1.5rem] border bg-[var(--card-elevated)] p-5 transition ${
focusedSetupId === entryId
? 'border-[var(--primary)] ring-2 ring-[var(--primary)]/20'
: 'border-[var(--border)]'
}`}
>
<div className="mb-3 flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">