feat(portfolio): drill into saved trade plans
This commit is contained in:
parent
ddf55d8f26
commit
36bd21b7cd
@ -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();
|
const now = Date.now();
|
||||||
fetchPositionsBootstrapMock.mockResolvedValue({
|
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: [{
|
orders: [{
|
||||||
id: 'entry-order',
|
id: 'entry-order',
|
||||||
order_id: 'entry-order',
|
order_id: 'entry-order',
|
||||||
@ -379,13 +386,14 @@ describe('PositionsTab DOM behavior', () => {
|
|||||||
render(<PositionsTab botState={buildBotState(now)} onManageHolding={onManageHolding} />);
|
render(<PositionsTab botState={buildBotState(now)} onManageHolding={onManageHolding} />);
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' }));
|
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]);
|
await user.click(manageButtons[0]);
|
||||||
|
|
||||||
expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({
|
expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
symbol: 'BTC/USDT',
|
symbol: 'BTC/USDT',
|
||||||
profileId: 'p1',
|
profileId: 'p1',
|
||||||
tradeId: 'TRD-POS-1'
|
tradeId: 'TRD-POS-1',
|
||||||
|
planEntryId: 'simple-setup-1'
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ interface HybridPosition {
|
|||||||
tradeId?: string;
|
tradeId?: string;
|
||||||
planMode?: 'short_term' | 'long_term';
|
planMode?: 'short_term' | 'long_term';
|
||||||
planState?: string | null;
|
planState?: string | null;
|
||||||
|
planEntryId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Profile {
|
interface Profile {
|
||||||
@ -418,7 +419,7 @@ export const assignLifecycleTradeIds = (
|
|||||||
export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) => {
|
export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) => {
|
||||||
const { user, profile } = useAuth();
|
const { user, profile } = useAuth();
|
||||||
const [manualPositions, setManualPositions] = useState<HybridPosition[]>([]);
|
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 [dbOrders, setDbOrders] = useState<RawOrderRecord[]>([]);
|
||||||
const [historyTradeKeys, setHistoryTradeKeys] = useState<string[]>([]);
|
const [historyTradeKeys, setHistoryTradeKeys] = useState<string[]>([]);
|
||||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||||
@ -514,11 +515,12 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
|
|||||||
if (!tradeId) return null;
|
if (!tradeId) return null;
|
||||||
const holdingMode = String(entry.holding_mode || '').trim().toLowerCase() === 'long_term' ? 'long_term' : 'short_term';
|
const holdingMode = String(entry.holding_mode || '').trim().toLowerCase() === 'long_term' ? 'long_term' : 'short_term';
|
||||||
return [tradeId, {
|
return [tradeId, {
|
||||||
|
entryId: String(entry.stock_instance_id || '').trim() || undefined,
|
||||||
holdingMode,
|
holdingMode,
|
||||||
automationState: String(entry.automation_state || '').trim() || null,
|
automationState: String(entry.automation_state || '').trim() || null,
|
||||||
}] as const;
|
}] 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);
|
setSimplePlanMetaByTradeId(nextSimplePlanMetaByTradeId);
|
||||||
}
|
}
|
||||||
@ -570,6 +572,9 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
|
|||||||
planState: p.tradeId && simplePlanMetaByTradeId[p.tradeId]
|
planState: p.tradeId && simplePlanMetaByTradeId[p.tradeId]
|
||||||
? simplePlanMetaByTradeId[p.tradeId].automationState || null
|
? simplePlanMetaByTradeId[p.tradeId].automationState || null
|
||||||
: null,
|
: null,
|
||||||
|
planEntryId: p.tradeId && simplePlanMetaByTradeId[p.tradeId]
|
||||||
|
? simplePlanMetaByTradeId[p.tradeId].entryId
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tradeId = String(normalized.tradeId || '').trim();
|
const tradeId = String(normalized.tradeId || '').trim();
|
||||||
@ -1078,7 +1083,16 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
|
|||||||
takeProfit: Number(position.takeProfit || 0) || undefined,
|
takeProfit: Number(position.takeProfit || 0) || undefined,
|
||||||
profileId: position.profileId,
|
profileId: position.profileId,
|
||||||
profileName: position.profileName,
|
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));
|
} as HybridPosition));
|
||||||
|
|
||||||
const deduped = new Map<string, HybridPosition>();
|
const deduped = new Map<string, HybridPosition>();
|
||||||
@ -1116,6 +1130,7 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
|
|||||||
selectedProfileId,
|
selectedProfileId,
|
||||||
filteredBotPositions,
|
filteredBotPositions,
|
||||||
filteredManualPositions,
|
filteredManualPositions,
|
||||||
|
simplePlanMetaByTradeId,
|
||||||
hasManagedSymbolScope,
|
hasManagedSymbolScope,
|
||||||
managedSymbolTokens,
|
managedSymbolTokens,
|
||||||
EPSILON
|
EPSILON
|
||||||
@ -1441,7 +1456,7 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
|
|||||||
onClick={() => onManageHolding(pos)}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{pos.source === 'BOT' && (
|
{pos.source === 'BOT' && (
|
||||||
|
|||||||
@ -37,11 +37,13 @@ export function PortfolioView() {
|
|||||||
<PositionsTab
|
<PositionsTab
|
||||||
botState={botState}
|
botState={botState}
|
||||||
onManageHolding={(position) => {
|
onManageHolding={(position) => {
|
||||||
const params = new URLSearchParams({
|
const params = position.planEntryId
|
||||||
mode: 'sell',
|
? new URLSearchParams({ setupId: position.planEntryId })
|
||||||
symbol: position.symbol,
|
: new URLSearchParams({
|
||||||
});
|
mode: 'sell',
|
||||||
if (position.tradeId) params.set('tradeId', position.tradeId);
|
symbol: position.symbol,
|
||||||
|
});
|
||||||
|
if (!position.planEntryId && position.tradeId) params.set('tradeId', position.tradeId);
|
||||||
navigate(`/simple?${params.toString()}`);
|
navigate(`/simple?${params.toString()}`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -629,8 +629,11 @@ export function SimpleView() {
|
|||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedHoldingTradeId, setSelectedHoldingTradeId] = useState<string | null>(null);
|
const [selectedHoldingTradeId, setSelectedHoldingTradeId] = useState<string | null>(null);
|
||||||
|
const [focusedSetupId, setFocusedSetupId] = useState<string | null>(null);
|
||||||
const marketPriceRequestSymbolRef = useRef<string>('');
|
const marketPriceRequestSymbolRef = useRef<string>('');
|
||||||
const consumedPrefillRef = useRef(false);
|
const consumedPrefillRef = useRef(false);
|
||||||
|
const consumedSetupFocusRef = useRef(false);
|
||||||
|
const setupCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
|
||||||
const normalizedSymbol = draft.symbol.trim().toUpperCase();
|
const normalizedSymbol = draft.symbol.trim().toUpperCase();
|
||||||
const symbolState = botState?.symbols && typeof botState.symbols === 'object' ? botState.symbols : {};
|
const symbolState = botState?.symbols && typeof botState.symbols === 'object' ? botState.symbols : {};
|
||||||
@ -825,6 +828,27 @@ export function SimpleView() {
|
|||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
}, [availableSellHoldings, searchParams, setSearchParams]);
|
}, [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) {
|
async function copyIdentifier(kind: 'trade' | 'order', value: string | null | undefined) {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
try {
|
try {
|
||||||
@ -1404,7 +1428,17 @@ export function SimpleView() {
|
|||||||
const canConvertToLongTerm = side === 'buy' && holdingMode === 'short_term' && runtimeSnapshot?.stage === 'filled';
|
const canConvertToLongTerm = side === 'buy' && holdingMode === 'short_term' && runtimeSnapshot?.stage === 'filled';
|
||||||
const canResumeExitManagement = side === 'buy' && holdingMode === 'long_term' && runtimeSnapshot?.stage === 'filled';
|
const canResumeExitManagement = side === 'buy' && holdingMode === 'long_term' && runtimeSnapshot?.stage === 'filled';
|
||||||
return (
|
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 className="mb-3 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user