diff --git a/backend/src/index.ts b/backend/src/index.ts index 54fbbc4..93acb5e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -65,6 +65,10 @@ const isSimpleSellEntry = (entry: ManualEntryRecord): boolean => { return String(entry.simple_side || '').trim().toLowerCase() === 'sell'; }; +const isSimpleLongTermHold = (entry: ManualEntryRecord): boolean => { + return String(entry.holding_mode || '').trim().toLowerCase() === 'long_term'; +}; + const isSimpleSubmittedStatus = (status?: string | null): boolean => { return String(status || '').trim().toLowerCase() === 'simple_entry_submitted'; }; @@ -291,6 +295,8 @@ async function main() { filled_quantity: activePosition.size, buy_time: entry.buy_time || new Date().toISOString(), status: 'simple_bought', + holding_mode: entry.holding_mode || 'short_term', + automation_state: isSimpleLongTermHold(entry) ? 'paused_long_term' : 'holding_managed', active: true, }); emitSimpleSetupEvent( @@ -363,6 +369,8 @@ async function main() { filled_quantity: result.adjustedQty ?? entry.filled_quantity, buy_time: new Date().toISOString(), status: 'simple_entry_submitted', + holding_mode: entry.holding_mode || 'short_term', + automation_state: 'entry_submitted', active: true, }); emitSimpleSetupEvent( @@ -390,6 +398,10 @@ async function main() { continue; } + if (isSimpleLongTermHold(entry)) { + continue; + } + const linkedTradeId = String(entry.linked_trade_id || '').trim(); const exitDedupKey = linkedTradeId ? `${symbol}::${linkedTradeId}` : symbol; if (processedSimpleExitKeys.has(exitDedupKey)) { @@ -417,6 +429,7 @@ async function main() { ...entry, active: false, status: 'sellCompleted', + automation_state: 'closed', sell_time: entry.sell_time || new Date().toISOString(), sell_price: currentPrice, }); @@ -455,6 +468,8 @@ async function main() { entry_price: activeEntryPrice, filled_quantity: entry.filled_quantity ?? activePosition.size, status: 'simple_exit_submitted', + holding_mode: entry.holding_mode || 'short_term', + automation_state: 'exit_submitted', active: true, }); emitSimpleSetupEvent( @@ -514,6 +529,8 @@ async function main() { filled_quantity: event.fillQty || simpleEntry.filled_quantity || simpleEntry.quantity, buy_time: simpleEntry.buy_time || new Date().toISOString(), status: 'simple_bought', + holding_mode: simpleEntry.holding_mode || 'short_term', + automation_state: isSimpleLongTermHold(simpleEntry) ? 'paused_long_term' : 'holding_managed', active: true, }); emitSimpleSetupEvent( @@ -534,6 +551,7 @@ async function main() { await saveManualEntryForUser(simpleEntry.user_id, { ...simpleEntry, status: 'simple_armed_buy', + automation_state: 'armed', active: true, buy_time: null, entry_price: null, @@ -557,6 +575,7 @@ async function main() { await saveManualEntryForUser(simpleExitEntry.user_id, { ...simpleExitEntry, status: 'simple_bought', + automation_state: isSimpleLongTermHold(simpleExitEntry) ? 'paused_long_term' : 'holding_managed', active: true, }); emitSimpleSetupEvent( @@ -603,6 +622,7 @@ async function main() { await saveManualEntryForUser(simpleExitEntry.user_id, { ...simpleExitEntry, status: 'simple_bought', + automation_state: isSimpleLongTermHold(simpleExitEntry) ? 'paused_long_term' : 'holding_managed', active: true, filled_quantity: applied.remainingSize, }); @@ -621,6 +641,7 @@ async function main() { ...simpleExitEntry, active: false, status: 'sellCompleted', + automation_state: 'closed', sell_time: simpleExitEntry.sell_time || new Date().toISOString(), sell_price: event.fillPrice || active.entryPrice, }); diff --git a/backend/src/services/manualEntryRepository.ts b/backend/src/services/manualEntryRepository.ts index 28e5e40..70c08db 100644 --- a/backend/src/services/manualEntryRepository.ts +++ b/backend/src/services/manualEntryRepository.ts @@ -40,6 +40,8 @@ export interface ManualEntryRecord { drop_trigger_mode?: string | null; profit_target_mode?: string | null; linked_trade_id?: string | null; + holding_mode?: string | null; + automation_state?: string | null; } type ManualEntryDocument = ManualEntryRecord & { @@ -64,7 +66,42 @@ function normalizeNullableString(value: unknown): string | null | undefined { return text ? text : null; } +function deriveSimpleAutomationState(status: string, holdingMode: string | null | undefined): string | null { + const normalizedStatus = String(status || '').trim().toLowerCase(); + const normalizedMode = String(holdingMode || '').trim().toLowerCase(); + if (normalizedMode === 'long_term') { + return normalizedStatus === 'sellcompleted' ? 'closed' : 'paused_long_term'; + } + switch (normalizedStatus) { + case 'simple_armed_buy': + case 'simple_armed_sell': + return 'armed'; + case 'simple_entry_submitted': + return 'entry_submitted'; + case 'simple_bought': + return 'holding_managed'; + case 'simple_exit_submitted': + return 'exit_submitted'; + case 'sellcompleted': + return 'closed'; + default: + return null; + } +} + function normalizeEntry(userId: string, input: Partial, existing?: ManualEntryRecord | null): ManualEntryRecord { + const workflowType = normalizeNullableString(input.workflow_type ?? existing?.workflow_type); + const holdingMode = normalizeNullableString( + input.holding_mode + ?? existing?.holding_mode + ?? (String(workflowType || '').trim().toLowerCase() === 'simple' ? 'short_term' : null) + ); + const status = String(input.status || existing?.status || 'active'); + const automationState = normalizeNullableString( + input.automation_state + ?? existing?.automation_state + ?? deriveSimpleAutomationState(status, holdingMode) + ); return { stock_instance_id: String(input.stock_instance_id || existing?.stock_instance_id || randomUUID()), symbol: String(input.symbol || existing?.symbol || '').trim(), @@ -80,7 +117,7 @@ function normalizeEntry(userId: string, input: Partial, exist sizing_mode: normalizeNullableString(input.sizing_mode ?? existing?.sizing_mode), filled_quantity: normalizeNullableNumber(input.filled_quantity ?? existing?.filled_quantity), notes: normalizeNullableString(input.notes ?? existing?.notes), - status: String(input.status || existing?.status || 'active'), + status, is_crypto: Boolean(input.is_crypto ?? existing?.is_crypto ?? false), is_real_trade: Boolean(input.is_real_trade ?? existing?.is_real_trade ?? false), label: normalizeNullableString(input.label ?? existing?.label), @@ -88,11 +125,13 @@ function normalizeEntry(userId: string, input: Partial, exist reference_price: normalizeNullableNumber(input.reference_price ?? existing?.reference_price), gain_threshold_for_sell: normalizeNullableNumber(input.gain_threshold_for_sell ?? existing?.gain_threshold_for_sell), drop_threshold_for_buy: normalizeNullableNumber(input.drop_threshold_for_buy ?? existing?.drop_threshold_for_buy), - workflow_type: normalizeNullableString(input.workflow_type ?? existing?.workflow_type), + workflow_type: workflowType, simple_side: normalizeNullableString(input.simple_side ?? existing?.simple_side), drop_trigger_mode: normalizeNullableString(input.drop_trigger_mode ?? existing?.drop_trigger_mode), profit_target_mode: normalizeNullableString(input.profit_target_mode ?? existing?.profit_target_mode), linked_trade_id: normalizeNullableString(input.linked_trade_id ?? existing?.linked_trade_id), + holding_mode: holdingMode, + automation_state: automationState, }; } diff --git a/web/src/lib/manualEntriesApi.ts b/web/src/lib/manualEntriesApi.ts index 7ff118d..90501e3 100644 --- a/web/src/lib/manualEntriesApi.ts +++ b/web/src/lib/manualEntriesApi.ts @@ -30,6 +30,8 @@ export interface ManualEntryPayload { drop_trigger_mode?: string | null; profit_target_mode?: string | null; linked_trade_id?: string | null; + holding_mode?: string | null; + automation_state?: string | null; } async function getAccessToken(): Promise { diff --git a/web/src/tabs/EntriesTab.tsx b/web/src/tabs/EntriesTab.tsx index d08e518..e5426e8 100644 --- a/web/src/tabs/EntriesTab.tsx +++ b/web/src/tabs/EntriesTab.tsx @@ -6,9 +6,9 @@ import type { BotState } from '../hooks/useWebSocket'; import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket'; import { createManualEntry, deleteManualEntry, fetchManualEntries, type ManualEntryPayload } from '../lib/manualEntriesApi'; -interface Entry { - stock_instance_id: string; - symbol: string; +interface Entry { + stock_instance_id: string; + symbol: string; active: boolean; user_id: string; buy_price?: string; @@ -23,13 +23,19 @@ interface Entry { is_real_trade: boolean; label?: string; entry_price?: string; - gain_threshold_for_sell?: string; - drop_threshold_for_buy?: string; -} - -export const filterEntriesByTab = (entries: Entry[], activeTab: string) => - entries.filter((entry) => { - switch (activeTab) { + gain_threshold_for_sell?: string; + drop_threshold_for_buy?: string; + workflow_type?: string; + holding_mode?: string; + automation_state?: string; +} + +export const filterEntriesByTab = (entries: Entry[], activeTab: string) => + entries.filter((entry) => { + if (String(entry.workflow_type || '').trim().toLowerCase() === 'simple') { + return false; + } + switch (activeTab) { case 'paperActive': return !entry.is_real_trade && entry.active && entry.status !== 'sellCompleted'; case 'paperCompleted': diff --git a/web/src/tabs/PositionsTab.tsx b/web/src/tabs/PositionsTab.tsx index 819b784..3c728ae 100644 --- a/web/src/tabs/PositionsTab.tsx +++ b/web/src/tabs/PositionsTab.tsx @@ -23,11 +23,13 @@ interface HybridPosition { pnl?: number | null; pnlPercent?: number | null; stopLoss?: number; - takeProfit?: number; - profileId?: string; - profileName?: string; - tradeId?: string; -} + takeProfit?: number; + profileId?: string; + profileName?: string; + tradeId?: string; + planMode?: 'short_term' | 'long_term'; + planState?: string | null; +} interface Profile { id: string; @@ -414,7 +416,8 @@ export const assignLifecycleTradeIds = ( export const PositionsTab = ({ botState }: PositionsTabProps) => { const { user, profile } = useAuth(); - const [manualPositions, setManualPositions] = useState([]); + const [manualPositions, setManualPositions] = useState([]); + const [simplePlanMetaByTradeId, setSimplePlanMetaByTradeId] = useState>({}); const [dbOrders, setDbOrders] = useState([]); const [historyTradeKeys, setHistoryTradeKeys] = useState([]); const [profiles, setProfiles] = useState([]); @@ -498,10 +501,26 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => { pnl: null, pnlPercent: null, stopLoss: entry.drop_threshold_for_buy, - takeProfit: entry.gain_threshold_for_sell + takeProfit: entry.gain_threshold_for_sell })).filter((p: { size: number; entryPrice: number; }) => p.size > 0 && p.entryPrice > 0); - setManualPositions(positions); - } + setManualPositions(positions); + + const nextSimplePlanMetaByTradeId = Object.fromEntries( + posData + .filter((entry: any) => String(entry.workflow_type || '').trim().toLowerCase() === 'simple') + .map((entry: any) => { + const tradeId = String(entry.linked_trade_id || '').trim(); + if (!tradeId) return null; + const holdingMode = String(entry.holding_mode || '').trim().toLowerCase() === 'long_term' ? 'long_term' : 'short_term'; + return [tradeId, { + holdingMode, + automationState: String(entry.automation_state || '').trim() || null, + }] as const; + }) + .filter(Boolean) as Array + ); + setSimplePlanMetaByTradeId(nextSimplePlanMetaByTradeId); + } setDbOrders(ordData || []); setHistoryTradeKeys(tradeKeys); @@ -543,8 +562,14 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => { takeProfit: p.takeProfit, profileId: p.profileId, profileName: p.profileName, - tradeId: p.tradeId, - }; + tradeId: p.tradeId, + planMode: p.tradeId && simplePlanMetaByTradeId[p.tradeId] + ? simplePlanMetaByTradeId[p.tradeId].holdingMode + : undefined, + planState: p.tradeId && simplePlanMetaByTradeId[p.tradeId] + ? simplePlanMetaByTradeId[p.tradeId].automationState || null + : null, + }; const tradeId = String(normalized.tradeId || '').trim(); const dedupeKey = tradeId @@ -567,7 +592,7 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => { }); } return Array.from(deduped.values()); - }, [botState.positions]); + }, [botState.positions, simplePlanMetaByTradeId]); const managedSymbols = useMemo(() => { return new Set(Object.keys(botState.symbols || {}).map((symbol) => String(symbol).toUpperCase())); @@ -1358,9 +1383,20 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => { )} - {pos.symbol} + {pos.symbol} {pos.side} + {pos.planMode ? ( +
+ + {pos.planMode === 'long_term' ? 'Long-term hold' : 'Short-term managed'} + +
+ ) : null} {formatDisplayQty(pos.size)} diff --git a/web/src/tabs/TabHelpers.test.ts b/web/src/tabs/TabHelpers.test.ts index 45346e1..ccf21db 100644 --- a/web/src/tabs/TabHelpers.test.ts +++ b/web/src/tabs/TabHelpers.test.ts @@ -70,7 +70,8 @@ describe('tab helper coverage', () => { const entries = [ { stock_instance_id: '1', is_real_trade: false, active: true, status: 'active' }, { stock_instance_id: '2', is_real_trade: false, active: false, status: 'sellCompleted' }, - { stock_instance_id: '3', is_real_trade: true, active: true, status: 'active' } + { stock_instance_id: '3', is_real_trade: true, active: true, status: 'active' }, + { stock_instance_id: '4', is_real_trade: false, active: true, status: 'simple_bought', workflow_type: 'simple' } ] as any; expect(filterEntriesByTab(entries, 'paperActive')).toHaveLength(1); diff --git a/web/src/views/SimpleView.test.ts b/web/src/views/SimpleView.test.ts index a06a677..fcf15fb 100644 --- a/web/src/views/SimpleView.test.ts +++ b/web/src/views/SimpleView.test.ts @@ -42,6 +42,8 @@ describe('SimpleView helpers', () => { profit_target_mode: 'percent', linked_trade_id: null, profile_id: null, + holding_mode: 'short_term', + automation_state: 'armed', buy_price: null, sell_price: null, buy_time: null, @@ -96,6 +98,8 @@ describe('SimpleView helpers', () => { profit_target_mode: 'dollar', linked_trade_id: 'TRD-123', profile_id: 'simple-profile', + holding_mode: 'short_term', + automation_state: 'armed', buy_price: null, sell_price: null, buy_time: null, @@ -143,6 +147,8 @@ describe('SimpleView helpers', () => { profit_target_mode: 'dollar', linked_trade_id: null, profile_id: null, + holding_mode: 'short_term', + automation_state: 'armed', buy_price: null, sell_price: null, buy_time: null, @@ -150,6 +156,40 @@ describe('SimpleView helpers', () => { }); }); + it('preserves long-term mode when editing an existing setup', () => { + const payload = buildSimpleSetupPayload({ + draft: { + symbol: 'aapl', + side: 'buy', + sizingMode: 'quantity', + quantity: '5', + amountUsd: '', + currentMarketPrice: '210.25', + dropMode: 'dollar', + dropValue: '12', + profitMode: 'percent', + profitValue: '8', + notes: 'Long-term compounder', + }, + existingId: 'simple-1', + existingEntry: { + stock_instance_id: 'simple-1', + symbol: 'AAPL', + active: true, + status: 'simple_bought', + is_crypto: false, + is_real_trade: false, + workflow_type: 'simple', + simple_side: 'buy', + holding_mode: 'long_term', + automation_state: 'paused_long_term', + } as any, + }); + + expect(payload.holding_mode).toBe('long_term'); + expect(payload.automation_state).toBe('paused_long_term'); + }); + it('rejects sell setups without an existing holding', () => { expect(() => buildSimpleSetupPayload({ draft: { diff --git a/web/src/views/SimpleView.tsx b/web/src/views/SimpleView.tsx index 38848a5..217a884 100644 --- a/web/src/views/SimpleView.tsx +++ b/web/src/views/SimpleView.tsx @@ -115,6 +115,35 @@ function normalizeMode(value: unknown, fallback: TriggerMode = 'percent'): Trigg return String(value || '').trim().toLowerCase() === 'dollar' ? 'dollar' : fallback; } +function normalizeHoldingMode(value: unknown): 'short_term' | 'long_term' { + return String(value || '').trim().toLowerCase() === 'long_term' ? 'long_term' : 'short_term'; +} + +function normalizeAutomationState(value: unknown, entry: ManualEntryPayload): string { + const explicit = String(value || '').trim().toLowerCase(); + if (explicit) return explicit; + const status = String(entry.status || '').trim().toLowerCase(); + const holdingMode = normalizeHoldingMode(entry.holding_mode); + if (holdingMode === 'long_term') { + return status === 'sellcompleted' ? 'closed' : 'paused_long_term'; + } + switch (status) { + case 'simple_armed_buy': + case 'simple_armed_sell': + return 'armed'; + case 'simple_entry_submitted': + return 'entry_submitted'; + case 'simple_bought': + return 'holding_managed'; + case 'simple_exit_submitted': + return 'exit_submitted'; + case 'sellcompleted': + return 'closed'; + default: + return 'unknown'; + } +} + function computeBuyTriggerPrice(draft: SimpleSetupDraft): number | null { const currentMarketPrice = parsePositiveNumber(draft.currentMarketPrice); const dropValue = parseNonNegativeNumber(draft.dropValue); @@ -145,6 +174,7 @@ export function buildSimpleSetupPayload(input: { draft: SimpleSetupDraft; existingId?: string; holding?: SimpleHolding | null; + existingEntry?: ManualEntryPayload | null; }): ManualEntryPayload { const symbol = input.draft.symbol.trim().toUpperCase(); if (!symbol) { @@ -166,6 +196,9 @@ export function buildSimpleSetupPayload(input: { const side = input.draft.side; const holding = input.holding || null; + const existingEntry = input.existingEntry || null; + const holdingMode = normalizeHoldingMode(existingEntry?.holding_mode); + const automationState = normalizeAutomationState(existingEntry?.automation_state, existingEntry || { status: side === 'buy' ? 'simple_armed_buy' : 'simple_armed_sell' } as ManualEntryPayload); if (side === 'sell' && !holding) { throw new Error('Sell setups require an existing Simple holding for this symbol.'); @@ -208,6 +241,8 @@ export function buildSimpleSetupPayload(input: { profit_target_mode: input.draft.profitMode, linked_trade_id: side === 'sell' ? holding!.tradeId || null : null, profile_id: side === 'sell' ? holding!.profileId || null : null, + holding_mode: existingEntry ? holdingMode : 'short_term', + automation_state: existingEntry ? automationState : 'armed', buy_price: null, sell_price: null, buy_time: null, @@ -341,6 +376,30 @@ function formatSetupStatus(status?: string | null): string { } } +function formatHoldingMode(mode?: string | null): string { + return normalizeHoldingMode(mode) === 'long_term' ? 'Long-term' : 'Short-term'; +} + +function formatAutomationState(entry: ManualEntryPayload): string { + const state = normalizeAutomationState(entry.automation_state, entry); + switch (state) { + case 'armed': + return 'Automation armed'; + case 'entry_submitted': + return 'Entry syncing'; + case 'holding_managed': + return 'Exit managed'; + case 'paused_long_term': + return 'Automation paused'; + case 'exit_submitted': + return 'Exit syncing'; + case 'closed': + return 'Closed'; + default: + return 'State syncing'; + } +} + function statusToneClasses(tone: SimpleRuntimeSnapshot['tone']): string { switch (tone) { case 'success': @@ -395,11 +454,18 @@ function describeNextAction( const symbol = String(entry.symbol || '').trim().toUpperCase() || 'This symbol'; if (!runtimeSnapshot) { + if (normalizeHoldingMode(entry.holding_mode) === 'long_term') { + return `${symbol} is being kept as a long-term hold. Automated exit monitoring is paused until you resume it.`; + } return side === 'buy' ? `${symbol} is saved and waiting for the configured buy trigger.` : `${symbol} is saved and waiting for an eligible holding to manage.`; } + if (normalizeHoldingMode(entry.holding_mode) === 'long_term' && runtimeSnapshot.stage === 'filled') { + return `${symbol} is held as a long-term position. No automated profit exit is currently armed.`; + } + switch (runtimeSnapshot.stage) { case 'armed': return side === 'buy' @@ -708,6 +774,15 @@ export function SimpleView() { setSavedSetups(normalizeSimpleEntries(entryRows)); } + async function updateSavedSetup(entryId: string, updater: (entry: ManualEntryPayload) => ManualEntryPayload) { + const existing = savedSetups.find((entry) => String(entry.stock_instance_id || '') === entryId); + if (!existing) return; + const updated = await updateManualEntry(entryId, updater(existing)); + setSavedSetups((prev) => normalizeSimpleEntries(prev.map((entry) => ( + String(entry.stock_instance_id || '') === entryId ? updated : entry + )))); + } + function setMarketPriceValue(value: string, source: MarketPriceSource) { setMarketPriceSource(source); updateDraft('currentMarketPrice', value); @@ -764,10 +839,14 @@ export function SimpleView() { setMessage(null); try { + const existingEntry = editingSetupId + ? (savedSetups.find((entry) => String(entry.stock_instance_id || '') === editingSetupId) || null) + : null; const payload = buildSimpleSetupPayload({ draft, existingId: editingSetupId || undefined, holding: draft.side === 'sell' ? matchingHolding : null, + existingEntry, }); if (editingSetupId) { @@ -818,6 +897,44 @@ export function SimpleView() { } } + async function handleConvertToLongTerm(entry: ManualEntryPayload) { + const entryId = String(entry.stock_instance_id || ''); + if (!entryId) return; + setError(null); + setMessage(null); + try { + await updateSavedSetup(entryId, (current) => ({ + ...current, + holding_mode: 'long_term', + automation_state: 'paused_long_term', + status: 'simple_bought', + active: true, + })); + setMessage(`${String(entry.symbol || '').trim().toUpperCase()} is now treated as a long-term hold. Automated exit monitoring is paused.`); + } catch (err: any) { + setError(err?.message ?? 'Failed to convert setup to long-term mode'); + } + } + + async function handleResumeExitManagement(entry: ManualEntryPayload) { + const entryId = String(entry.stock_instance_id || ''); + if (!entryId) return; + setError(null); + setMessage(null); + try { + await updateSavedSetup(entryId, (current) => ({ + ...current, + holding_mode: 'short_term', + automation_state: 'holding_managed', + status: 'simple_bought', + active: true, + })); + setMessage(`${String(entry.symbol || '').trim().toUpperCase()} is back under short-term exit management.`); + } catch (err: any) { + setError(err?.message ?? 'Failed to resume short-term exit management'); + } + } + const saveButtonLabel = editingSetupId ? 'Update setup' : 'Save setup'; const saveButtonDisabled = submitting || loadingPrice || (draft.side === 'sell' && !matchingHolding); @@ -1139,6 +1256,9 @@ export function SimpleView() { const nextActionText = describeNextAction(entry, runtimeSnapshot); const updatedAt = formatSetupUpdatedAt(entry); const eventHistory = deriveSimpleEventHistory(entry, runtimeEvents); + const holdingMode = normalizeHoldingMode(entry.holding_mode); + const canConvertToLongTerm = side === 'buy' && holdingMode === 'short_term' && runtimeSnapshot?.stage === 'filled'; + const canResumeExitManagement = side === 'buy' && holdingMode === 'long_term' && runtimeSnapshot?.stage === 'filled'; return (
@@ -1154,6 +1274,28 @@ export function SimpleView() {

{describeSavedSetup(entry)}

+ {canConvertToLongTerm ? ( + + ) : null} + {canResumeExitManagement ? ( + + ) : null}