diff --git a/web/src/tabs/PositionsTab.dom.test.tsx b/web/src/tabs/PositionsTab.dom.test.tsx
index 0dbba2e..67c6cab 100644
--- a/web/src/tabs/PositionsTab.dom.test.tsx
+++ b/web/src/tabs/PositionsTab.dom.test.tsx
@@ -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();
+
+ 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' };
diff --git a/web/src/tabs/PositionsTab.tsx b/web/src/tabs/PositionsTab.tsx
index e54ef8e..5c60a14 100644
--- a/web/src/tabs/PositionsTab.tsx
+++ b/web/src/tabs/PositionsTab.tsx
@@ -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([]);
- const [simplePlanMetaByTradeId, setSimplePlanMetaByTradeId] = useState>({});
+ const [simplePlanMetaByTradeId, setSimplePlanMetaByTradeId] = useState>({});
const [dbOrders, setDbOrders] = useState([]);
const [historyTradeKeys, setHistoryTradeKeys] = useState([]);
const [profiles, setProfiles] = useState([]);
@@ -520,7 +539,7 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
automationState: String(entry.automation_state || '').trim() || null,
}] as const;
})
- .filter(Boolean) as Array
+ .filter(Boolean) as Array
);
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> = {};
+ 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 = { ...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();
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));