diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 85a273a..b243511 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -137,6 +137,7 @@ export const config = { ENABLE_STRICT_CAPITAL_GUARD: process.env.ENABLE_STRICT_CAPITAL_GUARD !== 'false', STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT: parseFloat(process.env.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT || process.env.MAX_SLIPPAGE_PERCENT || '1.0'), STRICT_CAPITAL_FEE_BUFFER_PCT: parseFloat(process.env.STRICT_CAPITAL_FEE_BUFFER_PCT || '0.15'), + STRICT_CAPITAL_CRYPTO_MARKET_BUFFER_PCT: parseFloat(process.env.STRICT_CAPITAL_CRYPTO_MARKET_BUFFER_PCT || '3'), STRICT_CAPITAL_MIN_RESERVE_USD: parseFloat(process.env.STRICT_CAPITAL_MIN_RESERVE_USD || '0'), ENTRY_AUTO_REDUCE_ALERT_MIN_PCT: parseFloat(process.env.ENTRY_AUTO_REDUCE_ALERT_MIN_PCT || '0.05'), ENTRY_AUTO_REDUCE_ALERT_MIN_USD: parseFloat(process.env.ENTRY_AUTO_REDUCE_ALERT_MIN_USD || '25'), @@ -333,6 +334,7 @@ const dynamicConfigParsers: Record unknown> = { ENABLE_STRICT_CAPITAL_GUARD: (value) => toBoolean(value, config.ENABLE_STRICT_CAPITAL_GUARD), STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT: (value) => toNumber(value, config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT), STRICT_CAPITAL_FEE_BUFFER_PCT: (value) => toNumber(value, config.STRICT_CAPITAL_FEE_BUFFER_PCT), + STRICT_CAPITAL_CRYPTO_MARKET_BUFFER_PCT: (value) => toNumber(value, config.STRICT_CAPITAL_CRYPTO_MARKET_BUFFER_PCT), STRICT_CAPITAL_MIN_RESERVE_USD: (value) => toNumber(value, config.STRICT_CAPITAL_MIN_RESERVE_USD), ENTRY_AUTO_REDUCE_ALERT_MIN_PCT: (value) => toNumber(value, config.ENTRY_AUTO_REDUCE_ALERT_MIN_PCT), ENTRY_AUTO_REDUCE_ALERT_MIN_USD: (value) => toNumber(value, config.ENTRY_AUTO_REDUCE_ALERT_MIN_USD), diff --git a/backend/src/connectors/alpaca.ts b/backend/src/connectors/alpaca.ts index 8ef5961..7f11cf0 100644 --- a/backend/src/connectors/alpaca.ts +++ b/backend/src/connectors/alpaca.ts @@ -7,11 +7,11 @@ import { AccountSnapshot, IExchangeConnector, Candle, ExchangeCapabilities, Exch export class AlpacaConnector implements IExchangeConnector { private client: any; - constructor(apiKey?: string, apiSecret?: string) { + constructor(apiKey?: string, apiSecret?: string, options?: { paper?: boolean }) { this.client = new (Alpaca as any)({ keyId: apiKey || config.ALPACA_API_KEY, secretKey: apiSecret || config.ALPACA_API_SECRET, - paper: config.PAPER_TRADING, + paper: options?.paper ?? config.PAPER_TRADING, }); } diff --git a/backend/src/connectors/factory.ts b/backend/src/connectors/factory.ts index f7724c8..e5b12c9 100644 --- a/backend/src/connectors/factory.ts +++ b/backend/src/connectors/factory.ts @@ -3,15 +3,20 @@ import { AlpacaConnector } from './alpaca.js'; import { CCXTConnector } from './ccxt.js'; import { IExchangeConnector } from './types.js'; -export class ConnectorFactory { - static getConnector(): IExchangeConnector { - return this.getCustomConnector(config.PROVIDER); - } - - static getCustomConnector(provider: string, apiKey?: string, apiSecret?: string): IExchangeConnector { +export class ConnectorFactory { + static getConnector(): IExchangeConnector { + return this.getCustomConnector(config.PROVIDER); + } + + static getCustomConnector( + provider: string, + apiKey?: string, + apiSecret?: string, + options?: { paper?: boolean } + ): IExchangeConnector { switch (provider.toLowerCase()) { case 'alpaca': - return new AlpacaConnector(apiKey, apiSecret); + return new AlpacaConnector(apiKey, apiSecret, options); case 'ccxt': return new CCXTConnector(apiKey, apiSecret); default: diff --git a/backend/src/index.ts b/backend/src/index.ts index 93a93c1..3939f1d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -36,6 +36,17 @@ const toNonNegativeNumber = (value: unknown): number | null => { return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; }; +const resolveSimpleDesiredQty = (entry: ManualEntryRecord, referencePrice: number): number | null => { + const sizingMode = String(entry.sizing_mode || 'quantity').trim().toLowerCase(); + if (sizingMode === 'amount') { + const amountUsd = toPositiveNumber(entry.amount_usd); + if (!(amountUsd && amountUsd > 0) || !(referencePrice > 0)) return null; + const derivedQty = amountUsd / referencePrice; + return derivedQty > 0 ? derivedQty : null; + } + return toPositiveNumber(entry.quantity); +}; + const normalizeTriggerMode = (value: unknown): 'dollar' | 'percent' | null => { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'dollar' || normalized === 'percent') return normalized; @@ -166,7 +177,16 @@ async function main() { logger.info(`🚨 Bot Initialized for ${config.SYMBOLS.length} Symbols: ${config.SYMBOLS.join(', ')}`); // --- 0. Initialize Modular Exchanges --- - const dataExchange = ConnectorFactory.getCustomConnector(config.DATA_PROVIDER, primaryAlpacaKey, primaryAlpacaSecret); + const dataExchange = ConnectorFactory.getCustomConnector( + config.DATA_PROVIDER, + primaryAlpacaKey, + primaryAlpacaSecret, + { + paper: !isPlaceholder(config.ALPACA_API_KEY) + ? config.PAPER_TRADING + : preferredPrimaryUserCredentials.source === 'paper', + } + ); const apiServer = new ApiServer(config.API_PORT); apiServer.pruneSymbols(config.SYMBOLS); const proEngine = new ProStrategyEngine(dataExchange); @@ -263,9 +283,10 @@ async function main() { if (shouldArmSimpleBuy(entry)) { const currentPrice = resolveSimpleMarketPrice(entry) ?? toPositiveNumber(entry.reference_price); const triggerPrice = computeSimpleBuyTriggerPrice(entry); - const desiredQty = toPositiveNumber(entry.quantity); + if (!(currentPrice && currentPrice > 0) || !triggerPrice) continue; + const desiredQty = resolveSimpleDesiredQty(entry, currentPrice); const threshold = toNonNegativeNumber(entry.drop_threshold_for_buy); - if (!triggerPrice || !desiredQty || !(currentPrice && currentPrice > 0)) continue; + if (desiredQty === null) continue; if (currentPrice > triggerPrice) continue; const result = await ctx.manualTrader.executeRequest( @@ -285,6 +306,7 @@ async function main() { await saveManualEntryForUser(entry.user_id, { ...entry, profile_id: entry.profile_id || ctx.profileId, + linked_trade_id: result.tradeId || entry.linked_trade_id, filled_quantity: result.adjustedQty ?? entry.filled_quantity, buy_time: new Date().toISOString(), status: 'simple_entry_submitted', @@ -491,7 +513,12 @@ async function main() { return null; } - const userExecExchange = ConnectorFactory.getCustomConnector(config.EXECUTION_PROVIDER, userKey, userSecret); + const userExecExchange = ConnectorFactory.getCustomConnector( + config.EXECUTION_PROVIDER, + userKey, + userSecret, + { paper: preferredCredentials.source === 'paper' } + ); const userExecutor = new TradeExecutor(userExecExchange, apiServer, user.user_id, profile.id); userExecutor.setProfileSettings(profile); const monitoredSymbols = resolveProfileSymbols(profile); @@ -572,7 +599,12 @@ async function main() { if (!userKey || !userSecret) continue; const defaultProfileId = `default-${user.user_id}`; - const userExecExchange = ConnectorFactory.getCustomConnector(config.EXECUTION_PROVIDER, userKey, userSecret); + const userExecExchange = ConnectorFactory.getCustomConnector( + config.EXECUTION_PROVIDER, + userKey, + userSecret, + { paper: preferredCredentials.source === 'paper' } + ); const userExecutor = new TradeExecutor(userExecExchange, apiServer, user.user_id, defaultProfileId); await userExecutor.syncPositions(config.SYMBOLS); await userExecutor.rebuildStartupState(); diff --git a/backend/src/services/ManualTrader.ts b/backend/src/services/ManualTrader.ts index b743ea7..48d2d14 100644 --- a/backend/src/services/ManualTrader.ts +++ b/backend/src/services/ManualTrader.ts @@ -65,7 +65,7 @@ export class ManualTrader { userId?: string, sl?: number, tp?: number - ): Promise<{ success: boolean; orderId?: string; error?: string; adjustedQty?: number; requestedQty?: number; remainingCapitalUsd?: number }> { + ): Promise<{ success: boolean; orderId?: string; tradeId?: string; error?: string; adjustedQty?: number; requestedQty?: number; remainingCapitalUsd?: number }> { const signalSide = (side.toLowerCase() === 'buy') ? SignalDirection.BUY : SignalDirection.SELL; const requestedQty = Number(qty); if (!Number.isFinite(requestedQty) || requestedQty <= 0) { @@ -120,7 +120,7 @@ export class ManualTrader { signalSide, adjustedQty, type as 'market' | 'limit', - price, + type === 'market' ? estimatedPrice : price, sl, tp, userId diff --git a/backend/src/services/TradeExecutor.ts b/backend/src/services/TradeExecutor.ts index 1cfb2ff..51cc2b0 100644 --- a/backend/src/services/TradeExecutor.ts +++ b/backend/src/services/TradeExecutor.ts @@ -410,11 +410,34 @@ export class TradeExecutor { return false; } - private async hasActiveTradeId(tradeId?: string): Promise { - const normalized = String(tradeId || '').trim(); - if (!normalized) return false; + private async hasActiveTradeId(tradeId?: string): Promise { + const normalized = String(tradeId || '').trim(); + if (!normalized) return false; return await runtimeOrderRepository.hasActiveOrderForTradeId(normalized, this.profileId); - } + } + + private async hasOpenLifecycleEvidence(profileId: string, symbol: string): Promise { + const normalizedSymbol = String(symbol || '').trim(); + if (!profileId || !normalizedSymbol) return false; + + const openOrders = await runtimeOrderRepository.getOpenOrdersForProfile(profileId); + const symbolCandidates = new Set([ + normalizedSymbol, + normalizedSymbol.toUpperCase(), + SymbolMapper.toTradeSymbol(normalizedSymbol, String(config.EXECUTION_PROVIDER || '').trim() || 'alpaca'), + SymbolMapper.toTradeSymbol(normalizedSymbol.toUpperCase(), String(config.EXECUTION_PROVIDER || '').trim() || 'alpaca') + ].filter(Boolean).map((value) => String(value).trim().toUpperCase())); + + const hasEntryOrder = openOrders.some((order) => { + const orderSymbol = String(order?.symbol || '').trim().toUpperCase(); + if (!symbolCandidates.has(orderSymbol)) return false; + return normalizeOrderAction(order?.action) === 'ENTRY'; + }); + if (hasEntryOrder) return true; + + const virtualPosition = await runtimeOrderRepository.getVirtualOpenPosition(profileId, normalizedSymbol); + return Boolean(virtualPosition && Number(virtualPosition.qty) > 0); + } private async isTradeAlreadyFinalized(tradeId?: string): Promise { const normalized = String(tradeId || '').trim(); @@ -496,13 +519,19 @@ export class TradeExecutor { return config.ENABLE_STRICT_CAPITAL_GUARD !== false; } - private strictCapitalCostMultiplier(): number { - if (!this.isStrictCapitalGuardEnabled()) return 1; - const slippagePct = Math.max(0, Number(config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT || 0)); - const feePct = Math.max(0, Number(config.STRICT_CAPITAL_FEE_BUFFER_PCT || 0)); - const multiplier = 1 + ((slippagePct + feePct) / 100); - return Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1; - } + private strictCapitalCostMultiplier(orderType?: 'market' | 'limit', symbol?: string): number { + if (!this.isStrictCapitalGuardEnabled()) return 1; + const slippagePct = Math.max(0, Number(config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT || 0)); + const feePct = Math.max(0, Number(config.STRICT_CAPITAL_FEE_BUFFER_PCT || 0)); + const normalizedSymbol = String(symbol || '').trim().toUpperCase(); + const isCryptoMarket = (orderType || 'market') === 'market' + && (config.ASSET_CLASS === 'crypto' || normalizedSymbol.endsWith('USD') || normalizedSymbol.endsWith('USDT')); + const marketCryptoPct = isCryptoMarket + ? Math.max(0, Number(config.STRICT_CAPITAL_CRYPTO_MARKET_BUFFER_PCT || 3)) + : 0; + const multiplier = 1 + ((slippagePct + feePct + marketCryptoPct) / 100); + return Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1; + } private strictCapitalMinReserveUsd(): number { if (!this.isStrictCapitalGuardEnabled()) return 0; @@ -510,12 +539,13 @@ export class TradeExecutor { return Number.isFinite(reserve) && reserve > 0 ? reserve : 0; } - private clampBuyQtyToAvailableCapital( - symbol: string, - requestedQty: number, - requestedPrice: number | undefined, - availableCapital: number - ): number { + private clampBuyQtyToAvailableCapital( + symbol: string, + requestedQty: number, + requestedPrice: number | undefined, + availableCapital: number, + orderType: 'market' | 'limit' = 'market' + ): number { if (!Number.isFinite(requestedQty) || requestedQty <= 0) return 0; if (!Number.isFinite(availableCapital) || availableCapital <= 0) return 0; @@ -527,7 +557,7 @@ export class TradeExecutor { const unitPrice = this.resolveOrderReferencePrice(symbol, requestedPrice); if (!(unitPrice > 0)) return 0; - const effectiveUnitCost = unitPrice * this.strictCapitalCostMultiplier(); + const effectiveUnitCost = unitPrice * this.strictCapitalCostMultiplier(orderType, symbol); if (!(effectiveUnitCost > 0)) return 0; const maxQty = this.roundDownQty(budget / effectiveUnitCost); if (!(maxQty > 0)) return 0; @@ -606,10 +636,10 @@ export class TradeExecutor { return Number(Math.max(notional, minimum)); } - private estimateOrderCost(symbol: string, qty: number, price?: number): number { - if (!Number.isFinite(qty) || qty <= 0) return 0; - const unitPrice = this.resolveOrderReferencePrice(symbol, price); - const notional = qty * unitPrice * this.strictCapitalCostMultiplier(); + private estimateOrderCost(symbol: string, qty: number, price?: number, orderType: 'market' | 'limit' = 'market'): number { + if (!Number.isFinite(qty) || qty <= 0) return 0; + const unitPrice = this.resolveOrderReferencePrice(symbol, price); + const notional = qty * unitPrice * this.strictCapitalCostMultiplier(orderType, symbol); const minimum = Number.isFinite(config.MIN_NOTIONAL_USD) && config.MIN_NOTIONAL_USD > 0 ? config.MIN_NOTIONAL_USD : 0; const base = Math.max(notional, minimum); return Number(base + this.strictCapitalMinReserveUsd()); @@ -911,7 +941,7 @@ export class TradeExecutor { sl?: number, tp?: number, userIdOverride?: string - ): Promise<{ success: boolean, orderId?: string, error?: string }> { + ): Promise<{ success: boolean, orderId?: string, tradeId?: string, error?: string }> { if (healthTracker.isPaused()) { logger.info(`[TradeExecutor] 🛑 Entry BLOCKED for ${symbol}: Bot is PAUSED by admin.`); return { success: false, error: 'Trade execution is paused by administrator' }; @@ -924,7 +954,7 @@ export class TradeExecutor { const ledgerProfileId = this.getLedgerProfileId(); const tradeId = this.buildDeterministicTradeId(symbol, side); const finalUserId = userIdOverride || this.userId; - let reservedEstimate = ledgerProfileId ? this.estimateOrderCost(symbol, executionQty, price) : 0; + let reservedEstimate = ledgerProfileId ? this.estimateOrderCost(symbol, executionQty, price, type) : 0; let activeOrderId: string | undefined; let pendingCaptured = false; let capitalReserved = false; @@ -961,15 +991,19 @@ export class TradeExecutor { logger.warn(`[EntryLock] Entry locked for ${symbol} (profile=${ledgerProfileId})`); return { success: false, error: 'Entry already running for this profile and symbol' }; } - lockOwner = ownerCandidate; - lockAcquired = true; - const lifecycleActive = await entryLockService.isEntryInProgress(ledgerProfileId, normalizedSymbol); - if (lifecycleActive) { - await releaseLockIfHeld(); - logger.warn(`[Executor] Entry lifecycle already open for ${symbol} (profile=${ledgerProfileId})`); - return { success: false, error: 'Entry lifecycle already exists' }; - } - if (await this.hasActiveTradeId(tradeId)) { + lockOwner = ownerCandidate; + lockAcquired = true; + const lifecycleActive = await entryLockService.isEntryInProgress(ledgerProfileId, normalizedSymbol); + if (lifecycleActive) { + const corroborated = await this.hasOpenLifecycleEvidence(ledgerProfileId, normalizedSymbol); + if (corroborated) { + await releaseLockIfHeld(); + logger.warn(`[Executor] Entry lifecycle already open for ${symbol} (profile=${ledgerProfileId})`); + return { success: false, error: 'Entry lifecycle already exists' }; + } + logger.warn(`[Executor] Ignoring stale entry lifecycle marker for ${symbol} (profile=${ledgerProfileId})`); + } + if (await this.hasActiveTradeId(tradeId)) { await releaseLockIfHeld(); logger.warn(`[Executor] Duplicate ENTRY request blocked for ${symbol}`); return { success: false, error: 'Duplicate entry request blocked (idempotency window)' }; @@ -978,7 +1012,7 @@ export class TradeExecutor { const numericAvailable = Number.isFinite(Number(available ?? 0)) ? Number(available ?? 0) : 0; if (side === SignalDirection.BUY) { const requestedBeforeClamp = executionQty; - const clampedQty = this.clampBuyQtyToAvailableCapital(symbol, executionQty, price, numericAvailable); + const clampedQty = this.clampBuyQtyToAvailableCapital(symbol, executionQty, price, numericAvailable, type); if (!(clampedQty > 0)) { await releaseLockIfHeld(); logger.warn(`[Executor] Entry blocked for ${symbol}: unable to size BUY within available capital (${numericAvailable}).`); @@ -998,7 +1032,7 @@ export class TradeExecutor { executionQty = clampedQty; } } - reservedEstimate = this.estimateOrderCost(symbol, executionQty, price); + reservedEstimate = this.estimateOrderCost(symbol, executionQty, price, type); capitalReservationAmount = reservedEstimate; if (numericAvailable < reservedEstimate) { await releaseLockIfHeld(); @@ -1292,7 +1326,7 @@ export class TradeExecutor { const allPos = this.getAllActivePositions(); this.apiServer.updatePositions(allPos, this.profileId || 'global'); } - return { success: true, orderId: order.id }; + return { success: true, orderId: order.id, tradeId }; } catch (error: any) { await releaseCapitalOnAbort(); diff --git a/backend/src/services/distributedLockRepository.ts b/backend/src/services/distributedLockRepository.ts index 0e70ebe..49e6710 100644 --- a/backend/src/services/distributedLockRepository.ts +++ b/backend/src/services/distributedLockRepository.ts @@ -22,12 +22,19 @@ function isCosmosConfigured(): boolean { return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY); } +function sanitizeLockToken(value: string): string { + return String(value || '') + .trim() + .replace(/[^A-Za-z0-9._-]+/g, '_') + .replace(/^_+|_+$/g, '') || 'global'; +} + function buildEntryLockId(profileId: string, symbol?: string): string { - return `entry:${profileId}:${String(symbol || '').trim()}`; + return `entry:${sanitizeLockToken(profileId)}:${sanitizeLockToken(String(symbol || '').trim())}`; } function buildReconciliationLockId(profileId: string): string { - return `reconciliation:${profileId}`; + return `reconciliation:${sanitizeLockToken(profileId)}`; } async function readLock(id: string): Promise { diff --git a/backend/src/services/manualEntryRepository.ts b/backend/src/services/manualEntryRepository.ts index 2bd0126..28e5e40 100644 --- a/backend/src/services/manualEntryRepository.ts +++ b/backend/src/services/manualEntryRepository.ts @@ -23,6 +23,8 @@ export interface ManualEntryRecord { buy_time?: string | null; sell_time?: string | null; quantity?: number | null; + amount_usd?: number | null; + sizing_mode?: string | null; filled_quantity?: number | null; notes?: string | null; status: string; @@ -74,6 +76,8 @@ function normalizeEntry(userId: string, input: Partial, exist buy_time: normalizeNullableString(input.buy_time ?? existing?.buy_time), sell_time: normalizeNullableString(input.sell_time ?? existing?.sell_time), quantity: normalizeNullableNumber(input.quantity ?? existing?.quantity), + amount_usd: normalizeNullableNumber(input.amount_usd ?? existing?.amount_usd), + 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'), diff --git a/web/src/lib/manualEntriesApi.ts b/web/src/lib/manualEntriesApi.ts index 4d39176..7ff118d 100644 --- a/web/src/lib/manualEntriesApi.ts +++ b/web/src/lib/manualEntriesApi.ts @@ -13,6 +13,8 @@ export interface ManualEntryPayload { buy_time?: string | null; sell_time?: string | null; quantity?: number | null; + amount_usd?: number | null; + sizing_mode?: string | null; filled_quantity?: number | null; notes?: string | null; status: string; diff --git a/web/src/views/SimpleView.test.ts b/web/src/views/SimpleView.test.ts index dbe5509..a06a677 100644 --- a/web/src/views/SimpleView.test.ts +++ b/web/src/views/SimpleView.test.ts @@ -7,7 +7,9 @@ describe('SimpleView helpers', () => { draft: { symbol: 'aapl', side: 'buy', + sizingMode: 'quantity', quantity: '5', + amountUsd: '', currentMarketPrice: '210.25', dropMode: 'dollar', dropValue: '12', @@ -26,6 +28,8 @@ describe('SimpleView helpers', () => { is_real_trade: false, label: 'Simple', quantity: 5, + amount_usd: null, + sizing_mode: 'quantity', filled_quantity: null, notes: 'Long-term compounder', entry_price: null, @@ -50,7 +54,9 @@ describe('SimpleView helpers', () => { draft: { symbol: 'msft', side: 'sell', + sizingMode: 'quantity', quantity: '1', + amountUsd: '', currentMarketPrice: '420.50', dropMode: 'percent', dropValue: '', @@ -76,6 +82,8 @@ describe('SimpleView helpers', () => { is_real_trade: false, label: 'Simple', quantity: 10, + amount_usd: null, + sizing_mode: 'quantity', filled_quantity: 10, notes: null, entry_price: 380.25, @@ -95,12 +103,61 @@ describe('SimpleView helpers', () => { }); }); + it('builds an amount-sized buy setup payload', () => { + const payload = buildSimpleSetupPayload({ + draft: { + symbol: 'btc/usd', + side: 'buy', + sizingMode: 'amount', + quantity: '', + amountUsd: '250', + currentMarketPrice: '81366.105', + dropMode: 'percent', + dropValue: '0', + profitMode: 'dollar', + profitValue: '25', + notes: '', + }, + }); + + expect(payload).toEqual({ + stock_instance_id: undefined, + symbol: 'BTC/USD', + active: true, + status: 'simple_armed_buy', + is_crypto: false, + is_real_trade: false, + label: 'Simple', + quantity: null, + amount_usd: 250, + sizing_mode: 'amount', + filled_quantity: null, + notes: null, + entry_price: null, + reference_price: 81366.105, + gain_threshold_for_sell: 25, + drop_threshold_for_buy: 0, + workflow_type: 'simple', + simple_side: 'buy', + drop_trigger_mode: 'percent', + profit_target_mode: 'dollar', + linked_trade_id: null, + profile_id: null, + buy_price: null, + sell_price: null, + buy_time: null, + sell_time: null, + }); + }); + it('rejects sell setups without an existing holding', () => { expect(() => buildSimpleSetupPayload({ draft: { symbol: 'nvda', side: 'sell', + sizingMode: 'quantity', quantity: '5', + amountUsd: '', currentMarketPrice: '900', dropMode: 'percent', dropValue: '', diff --git a/web/src/views/SimpleView.tsx b/web/src/views/SimpleView.tsx index 736de20..87af9d8 100644 --- a/web/src/views/SimpleView.tsx +++ b/web/src/views/SimpleView.tsx @@ -23,7 +23,9 @@ type TriggerMode = 'dollar' | 'percent'; type SimpleSetupDraft = { symbol: string; side: SimpleSide; + sizingMode: 'quantity' | 'amount'; quantity: string; + amountUsd: string; currentMarketPrice: string; dropMode: TriggerMode; dropValue: string; @@ -42,11 +44,27 @@ type SimpleHolding = { 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', +]; const DEFAULT_DRAFT: SimpleSetupDraft = { symbol: '', side: 'buy', + sizingMode: 'quantity', quantity: '', + amountUsd: '', currentMarketPrice: '', dropMode: 'percent', dropValue: '', @@ -127,9 +145,7 @@ export function buildSimpleSetupPayload(input: { } const quantity = parsePositiveNumber(input.draft.quantity); - if (!quantity) { - throw new Error('Quantity is required'); - } + const amountUsd = parsePositiveNumber(input.draft.amountUsd); const profitValue = parsePositiveNumber(input.draft.profitValue); if (!profitValue) { @@ -147,6 +163,16 @@ export function buildSimpleSetupPayload(input: { 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, @@ -155,7 +181,9 @@ export function buildSimpleSetupPayload(input: { is_crypto: false, is_real_trade: false, label: 'Simple', - quantity: side === 'sell' ? holding!.size : quantity, + 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, @@ -194,9 +222,12 @@ function buildPreviewText(draft: SimpleSetupDraft, holding: SimpleHolding | null 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 [ - `Buy ${symbol} when price reaches ${triggerPrice.toFixed(4)} (${dropText}).`, + `${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(' '); } @@ -216,11 +247,38 @@ function buildPreviewText(draft: SimpleSetupDraft, holding: SimpleHolding | null return `Exit ${symbol} when price reaches ${profitTargetPrice.toFixed(4)} (${profitText}).`; } +function deriveRuntimeOrderStatus( + entry: ManualEntryPayload, + orders: Array>, + holdings: SimpleHolding[], +): string | 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(); + return status ? `Order: ${status}` : null; + } + } + + const symbol = String(entry.symbol || '').trim().toUpperCase(); + const side = normalizeSetupSide(entry.simple_side); + if (side === 'buy' && holdings.some((holding) => holding.symbol === symbol)) { + return 'Portfolio: open holding'; + } + if (String(entry.status || '').trim().toLowerCase() === 'sellcompleted') { + return 'Portfolio: closed'; + } + 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) : '', @@ -260,6 +318,11 @@ function normalizeSimpleEntries(entries: ManualEntryPayload[]): ManualEntryPaylo }); } +function normalizeKnownSymbol(value: unknown): string | null { + const normalized = String(value || '').trim().toUpperCase(); + return normalized ? normalized : null; +} + function describeSavedSetup(entry: ManualEntryPayload): string { const side = normalizeSetupSide(entry.simple_side); const symbol = String(entry.symbol || '').trim().toUpperCase(); @@ -269,6 +332,10 @@ function describeSavedSetup(entry: ManualEntryPayload): string { 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)); @@ -280,14 +347,14 @@ function describeSavedSetup(entry: ManualEntryPayload): string { const profitText = profitMode === 'dollar' ? `$${profitValue.toFixed(2)} above purchase` : `${profitValue}% above purchase`; - return `Buy ${symbol} ${dropText} ${referencePrice > 0 ? `(${referencePrice.toFixed(4)} ref` : ''}${triggerPrice ? ` → ${triggerPrice.toFixed(4)}` : ''}). Exit at ${profitText}.`; + 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 ${symbol} at ${profitText}${profitTargetPrice ? ` (${profitTargetPrice.toFixed(4)})` : ''}.`; + return `Exit full ${symbol} holding at ${profitText}${profitTargetPrice ? ` (${profitTargetPrice.toFixed(4)})` : ''}.`; } export function SimpleView() { @@ -322,11 +389,36 @@ export function SimpleView() { .filter((position) => position.symbol && position.size > 0 && position.entryPrice > 0); }, [botState.positions, simpleAutoProfile?.id]); + const runtimeOrders = useMemo(() => { + return Array.from(botState.orders.values()).filter((order) => { + if (!simpleAutoProfile?.id) return true; + return String(order.profileId || '').trim() === simpleAutoProfile.id; + }); + }, [botState.orders, simpleAutoProfile?.id]); + const matchingHolding = useMemo( () => simpleHoldings.find((holding) => holding.symbol === normalizedSymbol) || null, [simpleHoldings, normalizedSymbol], ); + const supportedSymbols = useMemo(() => { + const fromState = Object.keys(botState.symbols || {}).map(normalizeKnownSymbol).filter(Boolean) as string[]; + const fromSetups = savedSetups.map((entry) => normalizeKnownSymbol(entry.symbol)).filter(Boolean) as string[]; + const fromHoldings = simpleHoldings.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)); + }, [botState.symbols, savedSetups, simpleHoldings]); + + 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; @@ -518,8 +610,44 @@ export function SimpleView() { symbol: e.target.value.toUpperCase(), currentMarketPrice: '', }))} + list={SIMPLE_SYMBOL_DATALIST_ID} placeholder="AAPL" /> + + {supportedSymbols.map((symbol) => ( + + + Start typing to pick a supported symbol. Suggestions come from live market symbols plus common supported assets. + + {normalizedSymbol && !isSuggestedSymbol ? ( + + This symbol is not in the current supported suggestions. Double-check it before saving. + + ) : null} + {filteredSymbolSuggestions.length > 0 ? ( +
+ {filteredSymbolSuggestions.map((symbol) => ( + + ))} +
+ ) : null}