learning_ai_invt_trdg/web/src/views/SimpleView.tsx

1609 lines
69 KiB
TypeScript

import { useEffect, useMemo, useReducer, useRef, useState } from 'react';
import type { FormEvent } from 'react';
import { Pencil, RefreshCw, Trash2 } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { useAppContext } from '../context/AppContext';
import { fetchChartBars, fetchResearchProfile } from '../lib/marketApi';
import {
createManualEntry,
deleteManualEntry,
fetchManualEntries,
updateManualEntry,
type ManualEntryPayload,
} from '../lib/manualEntriesApi';
import { fetchTradeProfiles, type TradeProfilePayload } from '../lib/profileApi';
import { Button } from '../components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Input } from '../components/ui/input';
import { PageHeader } from '../components/ui/page-header';
import { Select } from '../components/ui/select';
import {
DEFAULT_TRADE_PLANS_UI_STATE,
reduceTradePlansUiState,
type MarketPriceSource,
type SimpleSetupDraft,
type SimpleSide,
type TriggerMode,
} from './tradePlansState';
import { useTradePlansNavigationState } from './useTradePlansNavigationState';
type SimpleHolding = {
symbol: string;
size: number;
entryPrice: number;
profileId?: string;
tradeId?: string;
};
type SimpleRuntimeSnapshot = {
stage: 'armed' | 'entry_submitted' | 'filled' | 'exit_submitted' | 'closed' | 'unknown';
label: string;
tone: 'neutral' | 'info' | 'success';
tradeId?: string;
orderId?: string;
};
type SimpleOperationalEvent = NonNullable<ReturnType<typeof useAppContext>['botState']['operationalEvents']>[number];
const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile';
const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase();
const SIMPLE_SYMBOL_DATALIST_ID = 'simple-supported-symbols';
const COMMON_SIMPLE_SYMBOLS = [
'AAPL',
'MSFT',
'NVDA',
'TSLA',
'SPY',
'QQQ',
'AMZN',
'META',
'BTC/USD',
'ETH/USD',
'SOL/USD',
];
function parsePositiveNumber(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function parseNonNegativeNumber(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
function roundPrice(value: number): number {
return Number(value.toFixed(4));
}
function matchesSimpleAutoProfile(profile: Pick<TradeProfilePayload, 'name'> | null | undefined) {
return String(profile?.name || '').trim().toLowerCase() === SIMPLE_AUTO_PROFILE_KEY;
}
function normalizeSetupSide(value: unknown): SimpleSide {
return String(value || '').trim().toLowerCase() === 'sell' ? 'sell' : 'buy';
}
function normalizeMode(value: unknown, fallback: TriggerMode = 'percent'): TriggerMode {
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);
if (!currentMarketPrice || dropValue === null) return null;
if (draft.dropMode === 'dollar') {
const trigger = currentMarketPrice - dropValue;
return trigger > 0 ? roundPrice(trigger) : null;
}
const trigger = currentMarketPrice * (1 - dropValue / 100);
return trigger > 0 ? roundPrice(trigger) : null;
}
function computeProfitTargetPrice(entryPrice: number | null, mode: TriggerMode, value: string): number | null {
const numericEntryPrice = entryPrice && entryPrice > 0 ? entryPrice : null;
const targetValue = parsePositiveNumber(value);
if (!numericEntryPrice || !targetValue) return null;
if (mode === 'dollar') {
return roundPrice(numericEntryPrice + targetValue);
}
return roundPrice(numericEntryPrice * (1 + targetValue / 100));
}
export function buildSimpleSetupPayload(input: {
draft: SimpleSetupDraft;
existingId?: string;
holding?: SimpleHolding | null;
existingEntry?: ManualEntryPayload | null;
}): ManualEntryPayload {
const symbol = input.draft.symbol.trim().toUpperCase();
if (!symbol) {
throw new Error('Symbol is required');
}
const currentMarketPrice = parsePositiveNumber(input.draft.currentMarketPrice);
if (!currentMarketPrice) {
throw new Error('No recent market price is available right now. The app uses live price when available, otherwise the latest close.');
}
const quantity = parsePositiveNumber(input.draft.quantity);
const amountUsd = parsePositiveNumber(input.draft.amountUsd);
const profitValue = parsePositiveNumber(input.draft.profitValue);
if (!profitValue) {
throw new Error('Profit target is required');
}
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 holding for this symbol.');
}
if (side === 'buy' && parseNonNegativeNumber(input.draft.dropValue) === null) {
throw new Error('Drop trigger is required for buy setups');
}
if (side === 'buy') {
if (input.draft.sizingMode === 'amount') {
if (!amountUsd) {
throw new Error('USD amount is required');
}
} else if (!quantity) {
throw new Error('Quantity is required');
}
}
return {
stock_instance_id: input.existingId,
symbol,
active: true,
status: side === 'buy' ? 'simple_armed_buy' : 'simple_armed_sell',
is_crypto: false,
is_real_trade: false,
label: 'Simple',
quantity: side === 'sell' ? holding!.size : (input.draft.sizingMode === 'quantity' ? quantity : null),
amount_usd: side === 'buy' && input.draft.sizingMode === 'amount' ? amountUsd : null,
sizing_mode: side === 'buy' ? input.draft.sizingMode : 'quantity',
filled_quantity: side === 'sell' ? holding!.size : null,
notes: input.draft.notes.trim() || null,
entry_price: side === 'sell' ? holding!.entryPrice : null,
reference_price: currentMarketPrice,
gain_threshold_for_sell: profitValue,
drop_threshold_for_buy: side === 'buy' ? parseNonNegativeNumber(input.draft.dropValue) : null,
workflow_type: 'simple',
simple_side: side,
drop_trigger_mode: side === 'buy' ? input.draft.dropMode : null,
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,
sell_time: null,
};
}
function buildPreviewText(draft: SimpleSetupDraft, holding: SimpleHolding | null): string | null {
const symbol = draft.symbol.trim().toUpperCase();
if (!symbol) return null;
if (draft.side === 'buy') {
const triggerPrice = computeBuyTriggerPrice(draft);
const profitTargetPrice = computeProfitTargetPrice(triggerPrice, draft.profitMode, draft.profitValue);
if (!triggerPrice) return `Buy ${symbol} after the configured drop trigger is hit.`;
const dropMagnitude = parseNonNegativeNumber(draft.dropValue);
const isImmediate = dropMagnitude === 0;
const dropText = isImmediate
? 'at the current market reference'
: draft.dropMode === 'dollar'
? `$${Number(draft.dropValue || 0).toFixed(2)} below current price`
: `${draft.dropValue || '0'}% below current price`;
const profitText = draft.profitMode === 'dollar'
? `$${Number(draft.profitValue || 0).toFixed(2)} above purchase`
: `${draft.profitValue || '0'}% above purchase`;
const sizeText = draft.sizingMode === 'amount'
? `Spend $${Number(draft.amountUsd || 0).toFixed(2)}`
: `Buy ${draft.quantity || '0'} units`;
return [
`${sizeText} of ${symbol} when price reaches ${triggerPrice.toFixed(4)} (${dropText}).`,
profitTargetPrice ? `Exit target stays armed at ${profitTargetPrice.toFixed(4)} (${profitText}).` : `Exit target uses ${profitText}.`,
].join(' ');
}
if (!holding) {
return `Sell ${symbol} only works when an eligible existing holding is available.`;
}
const profitTargetPrice = computeProfitTargetPrice(holding.entryPrice, draft.profitMode, draft.profitValue);
const profitText = draft.profitMode === 'dollar'
? `$${Number(draft.profitValue || 0).toFixed(2)} above purchase`
: `${draft.profitValue || '0'}% above purchase`;
if (!profitTargetPrice) {
return `Exit ${symbol} when the configured profit target is hit (${profitText}).`;
}
return `Exit ${symbol} when price reaches ${profitTargetPrice.toFixed(4)} (${profitText}).`;
}
function deriveRuntimeSnapshot(
entry: ManualEntryPayload,
orders: Array<Record<string, any>>,
holdings: SimpleHolding[],
): SimpleRuntimeSnapshot | null {
const linkedTradeId = String(entry.linked_trade_id || '').trim();
if (linkedTradeId) {
const matchingOrder = orders.find((order) => String(order?.trade_id || order?.tradeId || '').trim() === linkedTradeId);
if (matchingOrder) {
const status = String(matchingOrder.status || '').trim().toLowerCase();
const action = String(matchingOrder.action || '').trim().toUpperCase();
const orderId = String(matchingOrder.orderId || matchingOrder.order_id || matchingOrder.id || '').trim() || undefined;
if (action === 'EXIT') {
if (status === 'filled') {
return { stage: 'closed', label: 'Exit filled', tone: 'success', tradeId: linkedTradeId, orderId };
}
return { stage: 'exit_submitted', label: `Exit ${status || 'submitted'}`, tone: 'info', tradeId: linkedTradeId, orderId };
}
if (status === 'filled') {
return { stage: 'filled', label: 'Entry filled', tone: 'success', tradeId: linkedTradeId, orderId };
}
return { stage: 'entry_submitted', label: `Entry ${status || 'submitted'}`, tone: 'info', tradeId: linkedTradeId, orderId };
}
}
const symbol = String(entry.symbol || '').trim().toUpperCase();
const side = normalizeSetupSide(entry.simple_side);
const matchingHolding = holdings.find((holding) => holding.symbol === symbol && (!linkedTradeId || holding.tradeId === linkedTradeId || !holding.tradeId));
if (side === 'buy' && matchingHolding) {
return {
stage: 'filled',
label: 'Portfolio holding open',
tone: 'success',
tradeId: matchingHolding.tradeId || linkedTradeId || undefined,
};
}
if (String(entry.status || '').trim().toLowerCase() === 'sellcompleted') {
return { stage: 'closed', label: 'Position closed', tone: 'success', tradeId: linkedTradeId || undefined };
}
return null;
}
function buildDraftFromEntry(entry: ManualEntryPayload): SimpleSetupDraft {
return {
symbol: String(entry.symbol || '').trim().toUpperCase(),
side: normalizeSetupSide(entry.simple_side),
sizingMode: String(entry.sizing_mode || '').trim().toLowerCase() === 'amount' ? 'amount' : 'quantity',
quantity: entry.quantity ? String(entry.quantity) : '',
amountUsd: entry.amount_usd ? String(entry.amount_usd) : '',
currentMarketPrice: entry.reference_price ? Number(entry.reference_price).toFixed(4) : '',
dropMode: normalizeMode(entry.drop_trigger_mode, 'percent'),
dropValue: entry.drop_threshold_for_buy !== null && entry.drop_threshold_for_buy !== undefined ? String(entry.drop_threshold_for_buy) : '',
profitMode: normalizeMode(entry.profit_target_mode, 'percent'),
profitValue: entry.gain_threshold_for_sell !== null && entry.gain_threshold_for_sell !== undefined ? String(entry.gain_threshold_for_sell) : '',
notes: String(entry.notes || ''),
};
}
function inferMarketPriceSourceFromEntry(entry: ManualEntryPayload): MarketPriceSource {
return entry.reference_price ? 'reference_price' : null;
}
function formatSetupStatus(status?: string | null): string {
const normalized = String(status || '').trim().toLowerCase();
switch (normalized) {
case 'simple_armed_buy':
return 'Buy armed';
case 'simple_entry_submitted':
return 'Buy submitted';
case 'simple_bought':
return 'Holding';
case 'simple_armed_sell':
return 'Sell armed';
case 'simple_exit_submitted':
return 'Exit submitted';
case 'sellcompleted':
return 'Closed';
default:
return normalized || 'Unknown';
}
}
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':
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
case 'info':
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300';
default:
return 'border-[var(--border)] text-[var(--muted-foreground)]';
}
}
const SIMPLE_TIMELINE_STEPS: Array<SimpleRuntimeSnapshot['stage']> = [
'armed',
'entry_submitted',
'filled',
'exit_submitted',
'closed',
];
function isTimelineStepComplete(
current: SimpleRuntimeSnapshot['stage'] | undefined,
step: SimpleRuntimeSnapshot['stage'],
): boolean {
if (!current) return false;
const currentIndex = SIMPLE_TIMELINE_STEPS.indexOf(current);
const stepIndex = SIMPLE_TIMELINE_STEPS.indexOf(step);
return currentIndex >= 0 && stepIndex >= 0 && stepIndex <= currentIndex;
}
function formatTimelineStepLabel(step: SimpleRuntimeSnapshot['stage']) {
switch (step) {
case 'armed':
return 'Armed';
case 'entry_submitted':
return 'Entry sent';
case 'filled':
return 'Filled';
case 'exit_submitted':
return 'Exit sent';
case 'closed':
return 'Closed';
default:
return step;
}
}
function describeNextAction(
entry: ManualEntryPayload,
runtimeSnapshot: SimpleRuntimeSnapshot | null,
): string {
const side = normalizeSetupSide(entry.simple_side);
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'
? `${symbol} is waiting for the configured drop trigger before sending an entry order.`
: `${symbol} is waiting for the configured profit exit trigger.`;
case 'entry_submitted':
return `${symbol} entry order has been submitted and is waiting for exchange fill confirmation.`;
case 'filled':
return `${symbol} entry is filled. The setup is now monitoring for the configured profit exit.`;
case 'exit_submitted':
return `${symbol} exit order has been submitted and is waiting for exchange fill confirmation.`;
case 'closed':
return `${symbol} setup is complete. The linked position has been closed.`;
default:
return `${symbol} runtime state is syncing.`;
}
}
function formatSetupUpdatedAt(entry: ManualEntryPayload): string | null {
const raw = String(entry.sell_time || entry.buy_time || '').trim();
if (!raw) return null;
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toLocaleString();
}
function formatEventTimestamp(timestamp?: number): string | null {
if (!timestamp || !Number.isFinite(timestamp)) return null;
const parsed = new Date(timestamp);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toLocaleString();
}
function deriveSimpleEventHistory(
entry: ManualEntryPayload,
operationalEvents: SimpleOperationalEvent[],
): SimpleOperationalEvent[] {
const setupId = String(entry.stock_instance_id || '').trim();
const tradeId = String(entry.linked_trade_id || '').trim();
const symbol = String(entry.symbol || '').trim().toUpperCase();
return operationalEvents
.filter((event): event is SimpleOperationalEvent => Boolean(event) && event.type === 'SIMPLE_SETUP_UPDATE')
.filter((event) => {
const eventSetupId = String(event.setupId || '').trim();
const eventTradeId = String(event.tradeId || '').trim();
const eventSymbol = String(event.symbol || '').trim().toUpperCase();
if (setupId && eventSetupId === setupId) return true;
if (tradeId && eventTradeId === tradeId) return true;
return !setupId && !tradeId && !!symbol && eventSymbol === symbol;
})
.sort((left, right) => right.timestamp - left.timestamp)
.slice(0, 5);
}
function eventSeverityClasses(severity?: string) {
const normalized = String(severity || '').trim().toUpperCase();
if (normalized === 'ERROR') {
return 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300';
}
if (normalized === 'WARN') {
return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300';
}
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300';
}
function normalizeSimpleEntries(entries: ManualEntryPayload[]): ManualEntryPayload[] {
return entries
.filter((entry) => String(entry.workflow_type || '').trim().toLowerCase() === 'simple')
.sort((left, right) => {
const leftTimestamp = new Date(String(left.sell_time || left.buy_time || '')).getTime() || 0;
const rightTimestamp = new Date(String(right.sell_time || right.buy_time || '')).getTime() || 0;
return rightTimestamp - leftTimestamp;
});
}
function normalizeKnownSymbol(value: unknown): string | null {
const normalized = String(value || '').trim().toUpperCase();
return normalized ? normalized : null;
}
function normalizeRuntimeArray<T>(value: unknown): T[] {
if (Array.isArray(value)) return value as T[];
if (value && typeof value === 'object' && Symbol.iterator in (value as object)) {
try {
return Array.from(value as Iterable<T>);
} catch {
return [];
}
}
return [];
}
function isLikelySymbolCandidate(symbol: string): boolean {
return /^[A-Z0-9./-]{1,20}$/.test(symbol);
}
function extractReferencePriceFromResearchProfile(profile: any): number | null {
const candidates = [
profile?.price,
profile?.lastPrice,
profile?.currentPrice,
profile?.previousClose,
profile?.prevClose,
];
for (const candidate of candidates) {
const value = Number(candidate);
if (Number.isFinite(value) && value > 0) return value;
}
return null;
}
function describeSavedSetup(entry: ManualEntryPayload): string {
const side = normalizeSetupSide(entry.simple_side);
const symbol = String(entry.symbol || '').trim().toUpperCase();
const dropValue = Number(entry.drop_threshold_for_buy || 0);
const profitValue = Number(entry.gain_threshold_for_sell || 0);
const dropMode = normalizeMode(entry.drop_trigger_mode, 'percent');
const profitMode = normalizeMode(entry.profit_target_mode, 'percent');
const referencePrice = Number(entry.reference_price || 0);
const entryPrice = Number(entry.entry_price || 0);
const sizingMode = String(entry.sizing_mode || 'quantity').trim().toLowerCase();
const sizeText = sizingMode === 'amount'
? `$${Number(entry.amount_usd || 0).toFixed(2)} budget`
: `${Number(entry.quantity || 0).toFixed(6).replace(/\.?0+$/, '')} units`;
if (side === 'buy') {
const triggerPrice = computeBuyTriggerPrice(buildDraftFromEntry(entry));
const dropText = dropValue === 0
? 'at current reference'
: dropMode === 'dollar'
? `$${dropValue.toFixed(2)} below`
: `${dropValue}% below`;
const profitText = profitMode === 'dollar'
? `$${profitValue.toFixed(2)} above purchase`
: `${profitValue}% above purchase`;
return `Buy ${symbol} using ${sizeText} ${dropText} ${referencePrice > 0 ? `(${referencePrice.toFixed(4)} ref` : ''}${triggerPrice ? `${triggerPrice.toFixed(4)}` : ''}). Exit at ${profitText}.`;
}
const profitTargetPrice = computeProfitTargetPrice(entryPrice || null, profitMode, String(profitValue || ''));
const profitText = profitMode === 'dollar'
? `$${profitValue.toFixed(2)} above purchase`
: `${profitValue}% above purchase`;
return `Exit full ${symbol} holding at ${profitText}${profitTargetPrice ? ` (${profitTargetPrice.toFixed(4)})` : ''}.`;
}
export function SimpleView() {
const { botState } = useAppContext();
const [searchParams, setSearchParams] = useSearchParams();
const [profiles, setProfiles] = useState<TradeProfilePayload[]>([]);
const [savedSetups, setSavedSetups] = useState<ManualEntryPayload[]>([]);
const [uiState, dispatch] = useReducer(reduceTradePlansUiState, DEFAULT_TRADE_PLANS_UI_STATE);
const [submitting, setSubmitting] = useState(false);
const [loadingPrice, setLoadingPrice] = useState(false);
const marketPriceRequestSymbolRef = useRef<string>('');
const setupCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
const {
editingSetupId,
draft,
marketPriceSource,
copiedKey,
message,
error,
selectedHoldingTradeId,
focusedSetupId,
} = uiState;
const normalizedSymbol = draft.symbol.trim().toUpperCase();
const symbolState = botState?.symbols && typeof botState.symbols === 'object' ? botState.symbols : {};
const livePrice = normalizedSymbol ? Number(symbolState?.[normalizedSymbol]?.price || 0) : 0;
const simpleAutoProfile = useMemo(
() => profiles.find((profile) => matchesSimpleAutoProfile(profile)) || null,
[profiles],
);
const simpleHoldings = useMemo(() => {
const positions = normalizeRuntimeArray<typeof botState.positions[number]>(botState?.positions);
const simpleProfileId = simpleAutoProfile?.id;
return positions
.filter((position) => !simpleProfileId || position.profileId === simpleProfileId)
.map((position) => ({
symbol: String(position.symbol || '').trim().toUpperCase(),
size: Number(position.size || 0),
entryPrice: Number(position.entryPrice || 0),
profileId: position.profileId,
tradeId: position.tradeId,
}))
.filter((position) => position.symbol && position.size > 0 && position.entryPrice > 0);
}, [botState?.positions, simpleAutoProfile?.id]);
const availableSellHoldings = useMemo(() => {
const positions = normalizeRuntimeArray<typeof botState.positions[number]>(botState?.positions);
return positions
.map((position) => ({
symbol: String(position.symbol || '').trim().toUpperCase(),
size: Number(position.size || 0),
entryPrice: Number(position.entryPrice || 0),
profileId: position.profileId,
tradeId: position.tradeId,
}))
.filter((position) => position.symbol && position.size > 0 && position.entryPrice > 0 && position.profileId && position.tradeId)
.sort((left, right) => {
const bySymbol = left.symbol.localeCompare(right.symbol);
if (bySymbol !== 0) return bySymbol;
return String(left.tradeId || '').localeCompare(String(right.tradeId || ''));
});
}, [botState?.positions]);
const runtimeOrders = useMemo(() => {
const orders = normalizeRuntimeArray<Record<string, any>>(botState?.orders);
return orders.filter((order) => {
if (!simpleAutoProfile?.id) return true;
return String(order.profileId || '').trim() === simpleAutoProfile.id;
});
}, [botState?.orders, simpleAutoProfile?.id]);
const runtimeEvents = useMemo(
() => normalizeRuntimeArray<SimpleOperationalEvent>(botState?.operationalEvents),
[botState?.operationalEvents],
);
const selectedSellHolding = useMemo(() => {
if (selectedHoldingTradeId) {
return availableSellHoldings.find((holding) => holding.tradeId === selectedHoldingTradeId) || null;
}
return availableSellHoldings.find((holding) => holding.symbol === normalizedSymbol) || null;
}, [availableSellHoldings, normalizedSymbol, selectedHoldingTradeId]);
const supportedSymbols = useMemo(() => {
const fromState = Object.keys(symbolState || {}).map(normalizeKnownSymbol).filter(Boolean) as string[];
const fromSetups = savedSetups.map((entry) => normalizeKnownSymbol(entry.symbol)).filter(Boolean) as string[];
const fromHoldings = availableSellHoldings.map((holding) => normalizeKnownSymbol(holding.symbol)).filter(Boolean) as string[];
return Array.from(new Set([...fromState, ...fromSetups, ...fromHoldings, ...COMMON_SIMPLE_SYMBOLS])).sort((left, right) => left.localeCompare(right));
}, [symbolState, savedSetups, availableSellHoldings]);
const filteredSymbolSuggestions = useMemo(() => {
if (!normalizedSymbol) {
return supportedSymbols.slice(0, 8);
}
const startsWith = supportedSymbols.filter((symbol) => symbol.startsWith(normalizedSymbol));
const includes = supportedSymbols.filter((symbol) => !symbol.startsWith(normalizedSymbol) && symbol.includes(normalizedSymbol));
return [...startsWith, ...includes].slice(0, 8);
}, [normalizedSymbol, supportedSymbols]);
const isSuggestedSymbol = normalizedSymbol ? supportedSymbols.includes(normalizedSymbol) : false;
useEffect(() => {
let cancelled = false;
async function loadData() {
try {
const [profileRows, entryRows] = await Promise.all([
fetchTradeProfiles(),
fetchManualEntries(),
]);
if (cancelled) return;
setProfiles(profileRows);
setSavedSetups(normalizeSimpleEntries(entryRows));
} catch (err: any) {
if (cancelled) return;
dispatch({ type: 'set-error', value: err?.message ?? 'Failed to load Simple setups' });
}
}
void loadData();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!livePrice) return;
dispatch({ type: 'set-market-price-source', value: 'live' });
if (draft.currentMarketPrice !== livePrice.toFixed(4)) {
dispatch({ type: 'set-draft-field', key: 'currentMarketPrice', value: livePrice.toFixed(4) });
}
}, [livePrice]);
useEffect(() => {
if (!normalizedSymbol || livePrice > 0 || draft.currentMarketPrice.trim() || loadingPrice) {
return;
}
if (!isLikelySymbolCandidate(normalizedSymbol)) {
return;
}
if (!supportedSymbols.includes(normalizedSymbol)) {
return;
}
const timer = window.setTimeout(() => {
void handleLoadMarketPrice({ silent: true });
}, 250);
return () => {
window.clearTimeout(timer);
};
}, [normalizedSymbol, livePrice, draft.currentMarketPrice, loadingPrice, supportedSymbols]);
const previewText = useMemo(
() => buildPreviewText(draft, draft.side === 'sell' ? selectedSellHolding : null),
[draft, selectedSellHolding],
);
function updateDraft<K extends keyof SimpleSetupDraft>(key: K, value: SimpleSetupDraft[K]) {
if (key === 'side' && value === 'buy') {
dispatch({ type: 'set-selected-holding-trade-id', value: null });
}
if (key === 'symbol' && draft.side === 'sell') {
dispatch({ type: 'set-selected-holding-trade-id', value: null });
}
dispatch({ type: 'set-draft-field', key, value });
}
function applyHoldingToDraft(holding: SimpleHolding) {
dispatch({
type: 'apply-holding',
tradeId: holding.tradeId || null,
symbol: holding.symbol,
quantity: String(holding.size),
});
}
useEffect(() => {
if (draft.side !== 'sell') return;
if (selectedSellHolding) return;
if (availableSellHoldings.length === 0) return;
applyHoldingToDraft(availableSellHoldings[0]);
}, [draft.side, selectedSellHolding, availableSellHoldings]);
useTradePlansNavigationState({
searchParams,
setSearchParams,
savedSetups,
availableSellHoldings,
applyHoldingToDraft,
dispatch,
setupCardRefs,
});
async function copyIdentifier(kind: 'trade' | 'order', value: string | null | undefined) {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
const key = `${kind}:${value}`;
dispatch({ type: 'set-copied-key', value: key });
window.setTimeout(() => {
dispatch({ type: 'set-copied-key', value: null });
}, 1200);
} catch {
dispatch({ type: 'set-error', value: `Failed to copy ${kind} ID` });
}
}
async function refreshSetupList() {
const [profileRows, entryRows] = await Promise.all([
fetchTradeProfiles(),
fetchManualEntries(),
]);
setProfiles(profileRows);
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) {
dispatch({ type: 'set-market-price-source', value: source });
updateDraft('currentMarketPrice', value);
}
async function handleLoadMarketPrice(options?: { silent?: boolean }) {
if (!normalizedSymbol) return;
const requestSymbol = normalizedSymbol;
marketPriceRequestSymbolRef.current = requestSymbol;
setLoadingPrice(true);
if (!options?.silent) {
dispatch({ type: 'clear-feedback' });
}
try {
if (livePrice > 0) {
if (marketPriceRequestSymbolRef.current === requestSymbol) {
setMarketPriceValue(livePrice.toFixed(4), 'live');
}
return;
}
const bars = await fetchChartBars(requestSymbol, '1D');
const lastClose = Number(bars?.[bars.length - 1]?.close || 0);
if (Number.isFinite(lastClose) && lastClose > 0) {
if (marketPriceRequestSymbolRef.current === requestSymbol) {
setMarketPriceValue(lastClose.toFixed(4), 'latest_close');
}
} else {
const researchProfile = await fetchResearchProfile(requestSymbol).catch(() => null);
const fallbackReferencePrice = extractReferencePriceFromResearchProfile(researchProfile);
if (fallbackReferencePrice && marketPriceRequestSymbolRef.current === requestSymbol) {
setMarketPriceValue(fallbackReferencePrice.toFixed(4), 'reference_price');
return;
}
throw new Error('No live price or latest close is available for this symbol right now.');
}
} catch (err: any) {
if (!options?.silent && marketPriceRequestSymbolRef.current === requestSymbol) {
dispatch({ type: 'set-error', value: err?.message ?? 'Failed to load market data' });
}
} finally {
if (marketPriceRequestSymbolRef.current === requestSymbol) {
setLoadingPrice(false);
}
}
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setSubmitting(true);
dispatch({ type: 'clear-feedback' });
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' ? selectedSellHolding : null,
existingEntry,
});
if (editingSetupId) {
await updateManualEntry(editingSetupId, payload);
dispatch({ type: 'set-message', value: `Updated ${normalizedSymbol} Simple setup.` });
} else {
await createManualEntry({
...payload,
stock_instance_id: crypto.randomUUID(),
});
dispatch({ type: 'set-message', value: `Saved ${normalizedSymbol} Simple setup.` });
}
await refreshSetupList();
dispatch({
type: 'reset-form',
currentMarketPrice: draft.currentMarketPrice,
marketPriceSource: draft.currentMarketPrice ? marketPriceSource : null,
});
} catch (err: any) {
dispatch({ type: 'set-error', value: err?.message ?? 'Failed to save Simple setup' });
} finally {
setSubmitting(false);
}
}
function handleEdit(entry: ManualEntryPayload) {
dispatch({ type: 'set-editing-setup-id', value: String(entry.stock_instance_id || '') });
dispatch({ type: 'set-selected-holding-trade-id', value: String(entry.linked_trade_id || '').trim() || null });
dispatch({ type: 'set-market-price-source', value: inferMarketPriceSourceFromEntry(entry) });
dispatch({ type: 'replace-draft', draft: buildDraftFromEntry(entry) });
dispatch({ type: 'clear-feedback' });
}
async function handleDelete(entryId: string) {
if (!window.confirm('Delete this Simple setup?')) return;
try {
await deleteManualEntry(entryId);
if (editingSetupId === entryId) {
dispatch({ type: 'reset-form', currentMarketPrice: '', marketPriceSource: null });
}
await refreshSetupList();
} catch (err: any) {
dispatch({ type: 'set-error', value: err?.message ?? 'Failed to delete Simple setup' });
}
}
async function handleConvertToLongTerm(entry: ManualEntryPayload) {
const entryId = String(entry.stock_instance_id || '');
if (!entryId) return;
dispatch({ type: 'clear-feedback' });
try {
await updateSavedSetup(entryId, (current) => ({
...current,
holding_mode: 'long_term',
automation_state: 'paused_long_term',
status: 'simple_bought',
active: true,
}));
dispatch({ type: 'set-message', value: `${String(entry.symbol || '').trim().toUpperCase()} is now treated as a long-term hold. Automated exit monitoring is paused.` });
} catch (err: any) {
dispatch({ type: 'set-error', value: 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;
dispatch({ type: 'clear-feedback' });
try {
await updateSavedSetup(entryId, (current) => ({
...current,
holding_mode: 'short_term',
automation_state: 'holding_managed',
status: 'simple_bought',
active: true,
}));
dispatch({ type: 'set-message', value: `${String(entry.symbol || '').trim().toUpperCase()} is back under short-term exit management.` });
} catch (err: any) {
dispatch({ type: 'set-error', value: err?.message ?? 'Failed to resume short-term exit management' });
}
}
const saveButtonLabel = editingSetupId ? 'Update setup' : 'Save setup';
const saveButtonDisabled = submitting || loadingPrice || (draft.side === 'sell' && !selectedSellHolding);
return (
<div className="space-y-8">
<PageHeader
title="Trade Plans"
description="Create and manage short-term trade plans, then convert filled positions into long-term holds when you want to stop automated exits."
/>
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<Card>
<CardHeader>
<div>
<CardTitle className="uppercase">{editingSetupId ? 'Edit setup' : 'New setup'}</CardTitle>
<CardDescription>Build a short-term buy plan or attach a managed profit exit to an existing holding.</CardDescription>
</div>
<Button
type="button"
onClick={() => {
dispatch({
type: 'reset-form',
currentMarketPrice: draft.currentMarketPrice,
marketPriceSource: draft.currentMarketPrice ? marketPriceSource : null,
});
}}
variant="outline"
size="sm"
className="uppercase tracking-[0.2em]"
>
Reset
</Button>
</CardHeader>
<CardContent>
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="grid gap-3 md:grid-cols-2">
<button
type="button"
onClick={() => {
dispatch({ type: 'clear-feedback' });
dispatch({ type: 'set-selected-holding-trade-id', value: null });
updateDraft('side', 'buy');
}}
className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${
draft.side === 'buy'
? 'border-[var(--primary)] bg-[var(--accent-soft)]'
: 'border-[var(--border)] bg-[var(--card-elevated)]'
}`}
>
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-[var(--muted-foreground)]">Create plan</div>
<div className="mt-1 text-sm font-semibold text-[var(--foreground)]">New short-term buy plan</div>
<div className="mt-1 text-sm text-[var(--muted-foreground)]">Arm a dip-buy trigger and let the app manage the profit exit after fill.</div>
</button>
<button
type="button"
onClick={() => {
dispatch({ type: 'clear-feedback' });
if (availableSellHoldings.length > 0) {
applyHoldingToDraft(availableSellHoldings[0]);
} else {
updateDraft('side', 'sell');
}
}}
className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${
draft.side === 'sell'
? 'border-[var(--primary)] bg-[var(--accent-soft)]'
: 'border-[var(--border)] bg-[var(--card-elevated)]'
}`}
>
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-[var(--muted-foreground)]">Manage holding</div>
<div className="mt-1 text-sm font-semibold text-[var(--foreground)]">Attach an exit plan</div>
<div className="mt-1 text-sm text-[var(--muted-foreground)]">Choose an existing holding and place it back under managed profit-taking.</div>
</button>
</div>
{draft.side === 'sell' && (
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Existing holding</span>
<Select
value={selectedHoldingTradeId || ''}
onChange={(e) => {
const selected = availableSellHoldings.find((holding) => (holding.tradeId || '') === e.target.value);
if (selected) applyHoldingToDraft(selected);
}}
disabled={availableSellHoldings.length === 0}
>
{availableSellHoldings.length === 0 ? (
<option value="">No eligible holdings available</option>
) : (
availableSellHoldings.map((holding) => (
<option key={`${holding.symbol}:${holding.tradeId || 'holding'}`} value={holding.tradeId || ''}>
{holding.symbol} · {holding.size} @ {holding.entryPrice.toFixed(4)}
</option>
))
)}
</Select>
<span className="block text-[11px] text-[var(--muted-foreground)]">
Trade Plans can manage an existing filled holding by attaching a profit exit target to it.
</span>
</label>
)}
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Symbol</span>
<Input
value={draft.symbol}
onChange={(e) => {
dispatch({ type: 'set-market-price-source', value: null });
if (draft.side === 'sell') {
dispatch({ type: 'set-selected-holding-trade-id', value: null });
}
dispatch({
type: 'replace-draft',
draft: {
...draft,
symbol: e.target.value.toUpperCase(),
currentMarketPrice: '',
},
});
}}
list={SIMPLE_SYMBOL_DATALIST_ID}
placeholder="AAPL"
/>
<datalist id={SIMPLE_SYMBOL_DATALIST_ID}>
{supportedSymbols.map((symbol) => (
<option key={symbol} value={symbol} />
))}
</datalist>
<span className="block text-[11px] text-[var(--muted-foreground)]">
Start typing to pick a supported symbol. Suggestions come from live market symbols plus common supported assets.
</span>
{normalizedSymbol && !isSuggestedSymbol ? (
<span className="block text-[11px] text-amber-700 dark:text-amber-300">
This symbol is not in the current supported suggestions. Double-check it before saving.
</span>
) : null}
{filteredSymbolSuggestions.length > 0 ? (
<div className="flex flex-wrap gap-2 pt-1">
{filteredSymbolSuggestions.map((symbol) => (
<button
key={symbol}
type="button"
onClick={() => {
dispatch({ type: 'set-market-price-source', value: null });
if (draft.side === 'sell') {
dispatch({ type: 'set-selected-holding-trade-id', value: null });
}
dispatch({
type: 'replace-draft',
draft: {
...draft,
symbol,
currentMarketPrice: '',
},
});
}}
className={`rounded-full border px-3 py-1 text-[11px] font-semibold transition ${
symbol === normalizedSymbol
? 'border-[var(--primary)] bg-[var(--accent-soft)] text-[var(--primary)]'
: 'border-[var(--border)] bg-[var(--card-elevated)] text-[var(--muted-foreground)] hover:border-[var(--primary)] hover:text-[var(--foreground)]'
}`}
>
{symbol}
</button>
))}
</div>
) : null}
</label>
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Setup type</span>
<Select
value={draft.side}
onChange={(e) => updateDraft('side', e.target.value as SimpleSide)}
>
<option value="buy">Buy the dip + profit exit</option>
<option value="sell">Manage existing holding at profit</option>
</Select>
</label>
</div>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Market price (auto-fetched)</span>
<Input
value={draft.currentMarketPrice}
readOnly
className="bg-[var(--muted)] text-[var(--foreground)]"
/>
<div className="space-y-1">
<span className="block text-[11px] text-[var(--muted-foreground)]">
Uses live market price when available. Outside market hours, it falls back to the latest close.
</span>
<span className="block text-[11px] text-[var(--muted-foreground)]">
Price source:{' '}
<span className="font-semibold text-[var(--foreground)]">
{marketPriceSource === 'live'
? 'Live'
: marketPriceSource === 'latest_close'
? 'Latest close'
: marketPriceSource === 'reference_price'
? 'Reference price'
: 'Waiting for symbol data'}
</span>
</span>
</div>
</label>
<Button
type="button"
onClick={() => void handleLoadMarketPrice()}
className="self-end uppercase tracking-[0.2em]"
variant="outline"
disabled={loadingPrice || !normalizedSymbol}
>
<span className="inline-flex items-center gap-2">
<RefreshCw size={14} className={loadingPrice ? 'animate-spin' : ''} />
Refresh
</span>
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
{draft.side === 'buy' ? (
<>
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Sizing method</span>
<Select
value={draft.sizingMode}
onChange={(e) => updateDraft('sizingMode', e.target.value as 'quantity' | 'amount')}
>
<option value="quantity">Quantity / fractional shares</option>
<option value="amount">USD amount</option>
</Select>
</label>
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-500">
{draft.sizingMode === 'amount' ? 'Spend amount (USD)' : 'Planned quantity'}
</span>
<Input
value={draft.sizingMode === 'amount' ? draft.amountUsd : draft.quantity}
onChange={(e) => {
if (draft.sizingMode === 'amount') {
updateDraft('amountUsd', e.target.value);
} else {
updateDraft('quantity', e.target.value);
}
}}
placeholder={draft.sizingMode === 'amount' ? '250' : '10'}
/>
<span className="block text-[11px] text-[var(--muted-foreground)]">
Quantity supports fractional shares/coins. Amount spends an approximate USD budget at trigger time.
</span>
<span className="block text-[11px] text-[var(--muted-foreground)]">
Use quantity when you know the units you want. Use amount to budget dollars and let the app derive fractional size at entry.
</span>
</label>
</>
) : (
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-500">
Holding size
</span>
<Input
value={draft.side === 'sell' && selectedSellHolding ? String(selectedSellHolding.size) : draft.quantity}
onChange={(e) => updateDraft('quantity', e.target.value)}
readOnly={draft.side === 'sell' && !!selectedSellHolding}
className="read-only:bg-[var(--muted)]"
placeholder="10"
/>
</label>
)}
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Notes</span>
<Input
value={draft.notes}
onChange={(e) => updateDraft('notes', e.target.value)}
placeholder="Optional context"
/>
</label>
</div>
{draft.side === 'buy' && (
<div className="grid gap-4 rounded-[1.5rem] border border-[var(--border)] bg-[var(--card-elevated)] p-5 md:grid-cols-[0.55fr_0.45fr]">
<div className="space-y-3">
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Drop trigger</p>
<Select
value={draft.dropMode}
onChange={(e) => updateDraft('dropMode', e.target.value as TriggerMode)}
>
<option value="dollar">Dollar drop from current market</option>
<option value="percent">Percent drop from current market</option>
</Select>
</div>
<label className="space-y-3">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">
{draft.dropMode === 'dollar' ? 'Drop amount ($)' : 'Drop amount (%)'}
</span>
<Input
value={draft.dropValue}
onChange={(e) => updateDraft('dropValue', e.target.value)}
placeholder={draft.dropMode === 'dollar' ? '0.00' : '0'}
/>
</label>
</div>
)}
<div className="grid gap-4 rounded-[1.5rem] border border-[var(--border)] bg-[var(--card-elevated)] p-5 md:grid-cols-[0.55fr_0.45fr]">
<div className="space-y-3">
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Profit exit</p>
<Select
value={draft.profitMode}
onChange={(e) => updateDraft('profitMode', e.target.value as TriggerMode)}
>
<option value="dollar">Dollar gain from purchase</option>
<option value="percent">Percent gain from purchase</option>
</Select>
</div>
<label className="space-y-3">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">
{draft.profitMode === 'dollar' ? 'Profit target ($)' : 'Profit target (%)'}
</span>
<Input
value={draft.profitValue}
onChange={(e) => updateDraft('profitValue', e.target.value)}
placeholder={draft.profitMode === 'dollar' ? '7.50' : '10'}
/>
</label>
</div>
{draft.side === 'sell' && (
<div className={`rounded-[1.5rem] border px-4 py-4 text-sm ${
selectedSellHolding
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
: 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
}`}>
{selectedSellHolding
? `Holding ready: ${selectedSellHolding.symbol} · ${selectedSellHolding.size} units at ${selectedSellHolding.entryPrice.toFixed(4)}. Executed buys also appear in Portfolio as live positions.`
: 'No eligible live holding found for this symbol yet. Managed sell setups only arm against a current holding.'}
</div>
)}
{previewText && (
<div className="rounded-[1.5rem] border border-[var(--border)] bg-[var(--accent-soft)] px-5 py-4 text-sm text-[var(--foreground)]">
{previewText}
</div>
)}
{message && (
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-300">
{message}
</div>
)}
{error && (
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
<Button
type="submit"
disabled={saveButtonDisabled}
className="w-full uppercase tracking-[0.24em]"
size="lg"
>
{submitting ? 'Saving...' : saveButtonLabel}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader className="block">
<CardTitle className="uppercase">Saved setups</CardTitle>
<CardDescription>Review armed setups, current runtime order state, and whether an executed setup is now showing in Portfolio as an open holding.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{savedSetups.length === 0 && (
<div className="rounded-[1.5rem] border border-dashed border-[var(--border)] bg-[var(--card-elevated)] px-5 py-8 text-sm text-[var(--muted-foreground)]">
No Trade Plans saved yet.
</div>
)}
{savedSetups.map((entry) => {
const entryId = String(entry.stock_instance_id || '');
const side = normalizeSetupSide(entry.simple_side);
const isEditing = editingSetupId === entryId;
const runtimeSnapshot = deriveRuntimeSnapshot(entry, runtimeOrders, simpleHoldings);
const nextActionText = describeNextAction(entry, runtimeSnapshot);
const updatedAt = formatSetupUpdatedAt(entry);
const eventHistory = deriveSimpleEventHistory(entry, runtimeEvents);
const holdingMode = normalizeHoldingMode(entry.holding_mode);
const linkedHoldingTradeId = String(entry.linked_trade_id || '').trim();
const linkedHolding = side === 'sell'
? availableSellHoldings.find((holding) => {
if (linkedHoldingTradeId) {
return holding.tradeId === linkedHoldingTradeId;
}
return (
holding.symbol === String(entry.symbol || '').trim().toUpperCase()
&& String(holding.profileId || '').trim() === String(entry.profile_id || '').trim()
);
}) || null
: null;
const canConvertToLongTerm = side === 'buy' && holdingMode === 'short_term' && runtimeSnapshot?.stage === 'filled';
const canResumeExitManagement = side === 'buy' && holdingMode === 'long_term' && runtimeSnapshot?.stage === 'filled';
return (
<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>
<div className="flex items-center gap-3">
<h3 className="text-lg font-black uppercase text-[var(--foreground)]">{entry.symbol}</h3>
<span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-[0.2em] ${
side === 'buy' ? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' : 'bg-violet-500/10 text-violet-700 dark:text-violet-300'
}`}>
{side}
</span>
</div>
<p className="mt-2 text-sm text-[var(--muted-foreground)]">{describeSavedSetup(entry)}</p>
</div>
<div className="flex items-center gap-2">
{canConvertToLongTerm ? (
<Button
type="button"
onClick={() => void handleConvertToLongTerm(entry)}
variant="outline"
size="sm"
className="uppercase tracking-[0.18em]"
>
Convert to long-term
</Button>
) : null}
{canResumeExitManagement ? (
<Button
type="button"
onClick={() => void handleResumeExitManagement(entry)}
variant="outline"
size="sm"
className="uppercase tracking-[0.18em]"
>
Resume exit management
</Button>
) : null}
<Button
type="button"
onClick={() => handleEdit(entry)}
variant="outline"
size="sm"
className={isEditing ? 'border-emerald-500/30 text-emerald-700 dark:text-emerald-300' : 'uppercase tracking-[0.18em]'}
>
<span className="inline-flex items-center gap-2">
<Pencil size={14} />
Edit
</span>
</Button>
<Button
type="button"
onClick={() => void handleDelete(entryId)}
variant="destructive"
size="sm"
className="uppercase tracking-[0.18em]"
>
<span className="inline-flex items-center gap-2">
<Trash2 size={14} />
Delete
</span>
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-[11px] uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
<span className="rounded-full border border-[var(--border)] px-3 py-1">
{formatSetupStatus(entry.status)}
</span>
<span className="rounded-full border border-[var(--border)] px-3 py-1">
{formatHoldingMode(entry.holding_mode)}
</span>
<span className="rounded-full border border-[var(--border)] px-3 py-1">
{formatAutomationState(entry)}
</span>
{runtimeSnapshot ? (
<span className={`rounded-full border px-3 py-1 ${statusToneClasses(runtimeSnapshot.tone)}`}>
{runtimeSnapshot.label}
</span>
) : null}
<span className="rounded-full border border-[var(--border)] px-3 py-1">
{String(entry.sizing_mode || '').trim().toLowerCase() === 'amount'
? `Budget $${Number(entry.amount_usd || 0).toFixed(2)}`
: `Qty ${entry.quantity || entry.filled_quantity || 0}`}
</span>
{entry.reference_price ? (
<span className="rounded-full border border-[var(--border)] px-3 py-1">
Ref {Number(entry.reference_price).toFixed(4)}
</span>
) : null}
{entry.entry_price ? (
<span className="rounded-full border border-[var(--border)] px-3 py-1">
Entry {Number(entry.entry_price).toFixed(4)}
</span>
) : null}
{(runtimeSnapshot?.tradeId || entry.linked_trade_id) ? (
<button
type="button"
onClick={() => void copyIdentifier('trade', String(runtimeSnapshot?.tradeId || entry.linked_trade_id))}
className="rounded-full border border-[var(--border)] px-3 py-1 transition hover:border-[var(--primary)] hover:text-[var(--foreground)]"
>
{copiedKey === `trade:${String(runtimeSnapshot?.tradeId || entry.linked_trade_id)}`
? 'Trade copied'
: `Trade ${String(runtimeSnapshot?.tradeId || entry.linked_trade_id).slice(0, 18)}`}
</button>
) : null}
{runtimeSnapshot?.orderId ? (
<button
type="button"
onClick={() => void copyIdentifier('order', runtimeSnapshot.orderId)}
className="rounded-full border border-[var(--border)] px-3 py-1 transition hover:border-[var(--primary)] hover:text-[var(--foreground)]"
>
{copiedKey === `order:${runtimeSnapshot.orderId}`
? 'Order copied'
: `Order ${runtimeSnapshot.orderId.slice(0, 12)}`}
</button>
) : null}
{updatedAt ? (
<span className="rounded-full border border-[var(--border)] px-3 py-1 normal-case tracking-normal">
Updated {updatedAt}
</span>
) : null}
</div>
{side === 'sell' ? (
<div className="mt-4 rounded-2xl border border-[var(--border)] bg-[var(--background)] px-4 py-3">
<div className="text-[11px] font-black uppercase tracking-[0.22em] text-[var(--muted-foreground)]">
Linked holding
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-[var(--foreground)]">
<span className="rounded-full border border-[var(--border)] px-3 py-1">
{linkedHolding?.symbol || String(entry.symbol || '').trim().toUpperCase()}
</span>
{(linkedHolding?.tradeId || linkedHoldingTradeId) ? (
<span className="rounded-full border border-[var(--border)] px-3 py-1 font-mono text-xs">
Trade {linkedHolding?.tradeId || linkedHoldingTradeId}
</span>
) : null}
{(linkedHolding?.profileId || entry.profile_id) ? (
<span className="rounded-full border border-[var(--border)] px-3 py-1 font-mono text-xs">
Profile {String(linkedHolding?.profileId || entry.profile_id)}
</span>
) : null}
{linkedHolding?.entryPrice ? (
<span className="rounded-full border border-[var(--border)] px-3 py-1">
Entry {Number(linkedHolding.entryPrice).toFixed(4)}
</span>
) : null}
{linkedHolding?.size ? (
<span className="rounded-full border border-[var(--border)] px-3 py-1">
Qty {linkedHolding.size}
</span>
) : null}
</div>
</div>
) : null}
<div className="mt-4 grid gap-2 md:grid-cols-5">
{SIMPLE_TIMELINE_STEPS.map((step) => {
const complete = isTimelineStepComplete(runtimeSnapshot?.stage, step);
const isCurrent = runtimeSnapshot?.stage === step;
return (
<div
key={step}
className={`rounded-2xl border px-3 py-2 text-[11px] font-semibold ${
isCurrent
? 'border-[var(--primary)] bg-[var(--accent-soft)] text-[var(--primary)]'
: complete
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
: 'border-[var(--border)] bg-[var(--background)] text-[var(--muted-foreground)]'
}`}
>
{formatTimelineStepLabel(step)}
</div>
);
})}
</div>
<div className="mt-4 rounded-2xl border border-[var(--border)] bg-[var(--background)] px-4 py-3 text-sm text-[var(--muted-foreground)]">
<span className="font-semibold text-[var(--foreground)]">Next action:</span>{' '}
{nextActionText}
</div>
{eventHistory.length > 0 && (
<div className="mt-4 rounded-2xl border border-[var(--border)] bg-[var(--background)] px-4 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-[var(--foreground)]">Recent activity</div>
<div className="text-[11px] uppercase tracking-[0.18em] text-[var(--muted-foreground)]">
setup-level runtime history
</div>
</div>
<div className="space-y-2">
{eventHistory.map((event) => (
<div key={event.id} className={`rounded-2xl border px-3 py-3 ${eventSeverityClasses(event.severity)}`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] font-black uppercase tracking-[0.18em]">
{String(event.severity || 'INFO').toUpperCase()}
</div>
<div className="text-[11px] opacity-80">
{formatEventTimestamp(event.timestamp) || 'Just now'}
</div>
</div>
<div className="mt-1 text-sm leading-6">{event.message}</div>
<div className="mt-2 flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.14em] opacity-80">
{event.tradeId ? <span>Trade {event.tradeId.slice(0, 18)}</span> : null}
{event.orderId ? <span>Order {event.orderId.slice(0, 12)}</span> : null}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</div>
);
}