fix(portfolio): refresh plan metadata from runtime events

This commit is contained in:
root 2026-05-06 20:04:32 +00:00
parent ac353e8de5
commit ac17525124
2 changed files with 119 additions and 18 deletions

View File

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

View File

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