fix(portfolio): refresh plan metadata from runtime events
This commit is contained in:
parent
ac353e8de5
commit
ac17525124
@ -436,6 +436,58 @@ describe('PositionsTab DOM behavior', () => {
|
||||
}), 'create-exit-plan');
|
||||
});
|
||||
|
||||
it('uses runtime simple setup events to expose open-plan actions before bootstrap catches up', 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();
|
||||
const botState = buildBotState(now);
|
||||
botState.operationalEvents = [{
|
||||
id: 'evt-1',
|
||||
type: 'SIMPLE_SETUP_UPDATE',
|
||||
severity: 'INFO',
|
||||
message: 'BTC/USDT entry filled. Monitoring for the configured profit exit.',
|
||||
symbol: 'BTC/USDT',
|
||||
setupId: 'simple-setup-live',
|
||||
tradeId: 'TRD-POS-1',
|
||||
timestamp: now,
|
||||
}];
|
||||
|
||||
render(<PositionsTab botState={botState} onManageHolding={onManageHolding} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' }));
|
||||
const manageButtons = await screen.findAllByRole('button', { name: 'Open Plan' });
|
||||
await user.click(manageButtons[0]);
|
||||
|
||||
expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({
|
||||
symbol: 'BTC/USDT',
|
||||
profileId: 'p1',
|
||||
tradeId: 'TRD-POS-1',
|
||||
planEntryId: 'simple-setup-live'
|
||||
}), 'open-plan');
|
||||
});
|
||||
|
||||
it('handles non-admin bootstrap failures with empty-state fallback', async () => {
|
||||
authState.user = { id: 'user-2' };
|
||||
authState.profile = { role: 'trader' };
|
||||
|
||||
@ -12,6 +12,12 @@ interface PositionsTabProps {
|
||||
botState: BotState;
|
||||
onManageHolding?: (position: HybridPosition, action: 'open-plan' | 'create-exit-plan') => void;
|
||||
}
|
||||
|
||||
type SimplePlanMeta = {
|
||||
entryId?: string;
|
||||
holdingMode: 'short_term' | 'long_term';
|
||||
automationState?: string | null;
|
||||
};
|
||||
|
||||
interface HybridPosition {
|
||||
source: 'BOT' | 'MANUAL';
|
||||
@ -32,6 +38,19 @@ interface HybridPosition {
|
||||
planState?: string | null;
|
||||
planEntryId?: string;
|
||||
}
|
||||
|
||||
function inferAutomationStateFromSimpleEvent(message: string): string | null {
|
||||
const normalized = String(message || '').trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
if (normalized.includes('entry submitted')) return 'entry_submitted';
|
||||
if (normalized.includes('entry filled')) return 'holding_managed';
|
||||
if (normalized.includes('exit submitted')) return 'exit_submitted';
|
||||
if (normalized.includes('setup completed')) return 'closed';
|
||||
if (normalized.includes('entry did not fill')) return 'armed';
|
||||
if (normalized.includes('exit did not complete')) return 'holding_managed';
|
||||
if (normalized.includes('exit partially filled')) return 'holding_managed';
|
||||
return null;
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
@ -419,7 +438,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, { entryId?: string; holdingMode: 'short_term' | 'long_term'; automationState?: string | null }>>({});
|
||||
const [simplePlanMetaByTradeId, setSimplePlanMetaByTradeId] = useState<Record<string, SimplePlanMeta>>({});
|
||||
const [dbOrders, setDbOrders] = useState<RawOrderRecord[]>([]);
|
||||
const [historyTradeKeys, setHistoryTradeKeys] = useState<string[]>([]);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
@ -520,7 +539,7 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
|
||||
automationState: String(entry.automation_state || '').trim() || null,
|
||||
}] as const;
|
||||
})
|
||||
.filter(Boolean) as Array<readonly [string, { entryId?: string; holdingMode: 'short_term' | 'long_term'; automationState?: string | null }]>
|
||||
.filter(Boolean) as Array<readonly [string, SimplePlanMeta]>
|
||||
);
|
||||
setSimplePlanMetaByTradeId(nextSimplePlanMetaByTradeId);
|
||||
}
|
||||
@ -538,9 +557,39 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
|
||||
window.clearInterval(refreshTimer);
|
||||
};
|
||||
}, [user, profile?.role]);
|
||||
|
||||
// 2. Build bot positions from real-time Socket.IO data
|
||||
const botPositionsRaw: HybridPosition[] = useMemo(() => {
|
||||
|
||||
const runtimeSimplePlanMetaByTradeId = useMemo(() => {
|
||||
const merged: Record<string, Partial<SimplePlanMeta>> = {};
|
||||
const events = Array.isArray(botState.operationalEvents) ? botState.operationalEvents : [];
|
||||
for (const event of events) {
|
||||
if (!event || event.type !== 'SIMPLE_SETUP_UPDATE') continue;
|
||||
const tradeId = String(event.tradeId || '').trim();
|
||||
if (!tradeId) continue;
|
||||
const nextAutomationState = inferAutomationStateFromSimpleEvent(event.message);
|
||||
const existing = merged[tradeId] || {};
|
||||
merged[tradeId] = {
|
||||
entryId: String(event.setupId || '').trim() || existing.entryId,
|
||||
automationState: nextAutomationState ?? existing.automationState ?? null,
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}, [botState.operationalEvents]);
|
||||
|
||||
const effectiveSimplePlanMetaByTradeId = useMemo(() => {
|
||||
const merged: Record<string, SimplePlanMeta> = { ...simplePlanMetaByTradeId };
|
||||
for (const [tradeId, runtimeMeta] of Object.entries(runtimeSimplePlanMetaByTradeId)) {
|
||||
const existing = merged[tradeId];
|
||||
merged[tradeId] = {
|
||||
entryId: runtimeMeta.entryId || existing?.entryId,
|
||||
holdingMode: existing?.holdingMode || 'short_term',
|
||||
automationState: runtimeMeta.automationState ?? existing?.automationState ?? null,
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}, [runtimeSimplePlanMetaByTradeId, simplePlanMetaByTradeId]);
|
||||
|
||||
// 2. Build bot positions from real-time Socket.IO data
|
||||
const botPositionsRaw: HybridPosition[] = useMemo(() => {
|
||||
const deduped = new Map<string, HybridPosition>();
|
||||
const score = (position: HybridPosition): number => {
|
||||
const tradeScore = position.tradeId ? 4 : 0;
|
||||
@ -566,14 +615,14 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
|
||||
profileId: p.profileId,
|
||||
profileName: p.profileName,
|
||||
tradeId: p.tradeId,
|
||||
planMode: p.tradeId && simplePlanMetaByTradeId[p.tradeId]
|
||||
? simplePlanMetaByTradeId[p.tradeId].holdingMode
|
||||
planMode: p.tradeId && effectiveSimplePlanMetaByTradeId[p.tradeId]
|
||||
? effectiveSimplePlanMetaByTradeId[p.tradeId].holdingMode
|
||||
: undefined,
|
||||
planState: p.tradeId && simplePlanMetaByTradeId[p.tradeId]
|
||||
? simplePlanMetaByTradeId[p.tradeId].automationState || null
|
||||
planState: p.tradeId && effectiveSimplePlanMetaByTradeId[p.tradeId]
|
||||
? effectiveSimplePlanMetaByTradeId[p.tradeId].automationState || null
|
||||
: null,
|
||||
planEntryId: p.tradeId && simplePlanMetaByTradeId[p.tradeId]
|
||||
? simplePlanMetaByTradeId[p.tradeId].entryId
|
||||
planEntryId: p.tradeId && effectiveSimplePlanMetaByTradeId[p.tradeId]
|
||||
? effectiveSimplePlanMetaByTradeId[p.tradeId].entryId
|
||||
: undefined,
|
||||
};
|
||||
|
||||
@ -598,7 +647,7 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
|
||||
});
|
||||
}
|
||||
return Array.from(deduped.values());
|
||||
}, [botState.positions, simplePlanMetaByTradeId]);
|
||||
}, [botState.positions, effectiveSimplePlanMetaByTradeId]);
|
||||
|
||||
const managedSymbols = useMemo(() => {
|
||||
return new Set(Object.keys(botState.symbols || {}).map((symbol) => String(symbol).toUpperCase()));
|
||||
@ -1084,14 +1133,14 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
|
||||
profileId: position.profileId,
|
||||
profileName: position.profileName,
|
||||
tradeId: position.tradeId,
|
||||
planMode: position.tradeId && simplePlanMetaByTradeId[position.tradeId]
|
||||
? simplePlanMetaByTradeId[position.tradeId].holdingMode
|
||||
planMode: position.tradeId && effectiveSimplePlanMetaByTradeId[position.tradeId]
|
||||
? effectiveSimplePlanMetaByTradeId[position.tradeId].holdingMode
|
||||
: undefined,
|
||||
planState: position.tradeId && simplePlanMetaByTradeId[position.tradeId]
|
||||
? simplePlanMetaByTradeId[position.tradeId].automationState || null
|
||||
planState: position.tradeId && effectiveSimplePlanMetaByTradeId[position.tradeId]
|
||||
? effectiveSimplePlanMetaByTradeId[position.tradeId].automationState || null
|
||||
: null,
|
||||
planEntryId: position.tradeId && simplePlanMetaByTradeId[position.tradeId]
|
||||
? simplePlanMetaByTradeId[position.tradeId].entryId
|
||||
planEntryId: position.tradeId && effectiveSimplePlanMetaByTradeId[position.tradeId]
|
||||
? effectiveSimplePlanMetaByTradeId[position.tradeId].entryId
|
||||
: undefined
|
||||
} as HybridPosition));
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user