diff --git a/backend/src/index.ts b/backend/src/index.ts index 493c420..7abbdfa 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -21,6 +21,80 @@ import { reconciliationWatchdogAutoResumeService } from './services/reconciliati import { listActiveTradeProfiles } from './services/profileRepository.js'; import { listActiveTradingUsers } from './services/userRepository.js'; import * as runtimeOrderRepository from './services/runtimeOrderRepository.js'; +import { listManualEntries, saveManualEntryForUser, type ManualEntryRecord } from './services/manualEntryRepository.js'; + +const SIMPLE_WORKFLOW_TYPE = 'simple'; +const SIMPLE_WORKER_INTERVAL_MS = 15_000; + +const toPositiveNumber = (value: unknown): number | null => { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : null; +}; + +const normalizeTriggerMode = (value: unknown): 'dollar' | 'percent' | null => { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'dollar' || normalized === 'percent') return normalized; + return null; +}; + +const isSimpleWorkflowEntry = (entry: ManualEntryRecord | null | undefined): entry is ManualEntryRecord => { + return String(entry?.workflow_type || '').trim().toLowerCase() === SIMPLE_WORKFLOW_TYPE; +}; + +const isSimpleBuyEntry = (entry: ManualEntryRecord): boolean => { + return String(entry.simple_side || '').trim().toLowerCase() === 'buy'; +}; + +const isSimpleSellEntry = (entry: ManualEntryRecord): boolean => { + return String(entry.simple_side || '').trim().toLowerCase() === 'sell'; +}; + +const isSimpleSubmittedStatus = (status?: string | null): boolean => { + return String(status || '').trim().toLowerCase() === 'simple_entry_submitted'; +}; + +const isSimpleBoughtStatus = (status?: string | null): boolean => { + return String(status || '').trim().toLowerCase() === 'simple_bought'; +}; + +const isSimpleSellArmedStatus = (status?: string | null): boolean => { + return String(status || '').trim().toLowerCase() === 'simple_armed_sell'; +}; + +const isSimpleExitSubmittedStatus = (status?: string | null): boolean => { + return String(status || '').trim().toLowerCase() === 'simple_exit_submitted'; +}; + +const shouldArmSimpleBuy = (entry: ManualEntryRecord): boolean => { + return String(entry.status || '').trim().toLowerCase() === 'simple_armed_buy'; +}; + +const computeSimpleBuyTriggerPrice = (entry: ManualEntryRecord): number | null => { + const referencePrice = toPositiveNumber(entry.reference_price); + const threshold = toPositiveNumber(entry.drop_threshold_for_buy); + const mode = normalizeTriggerMode(entry.drop_trigger_mode); + if (!referencePrice || !threshold || !mode) return null; + + if (mode === 'dollar') { + const triggerPrice = referencePrice - threshold; + return triggerPrice > 0 ? Number(triggerPrice.toFixed(4)) : null; + } + + const triggerPrice = referencePrice * (1 - (threshold / 100)); + return triggerPrice > 0 ? Number(triggerPrice.toFixed(4)) : null; +}; + +const computeSimpleProfitTargetPrice = (entryPrice: number, entry: ManualEntryRecord): number | null => { + const threshold = toPositiveNumber(entry.gain_threshold_for_sell); + const mode = normalizeTriggerMode(entry.profit_target_mode); + if (!(entryPrice > 0) || !threshold || !mode) return null; + + if (mode === 'dollar') { + return Number((entryPrice + threshold).toFixed(4)); + } + + return Number((entryPrice * (1 + (threshold / 100))).toFixed(4)); +}; async function main() { logger.info(`Starting ${config.PRODUCT_ID} trading backend...`); @@ -105,6 +179,153 @@ async function main() { } return Array.from(symbols); }; + const getSimpleWorkerContext = (entry: ManualEntryRecord): UserContext | null => { + const requestedProfileId = String(entry.profile_id || '').trim(); + const requestedUserId = String(entry.user_id || '').trim(); + if (requestedProfileId) { + const byProfile = userContexts.find((ctx) => ctx.profileId === requestedProfileId); + if (byProfile) return byProfile; + } + if (requestedUserId) { + const byUser = userContexts.find((ctx) => ctx.userId === requestedUserId); + if (byUser) return byUser; + } + return null; + }; + const resolveSimpleMarketPrice = (entry: ManualEntryRecord): number | null => { + const symbol = String(entry.symbol || '').trim().toUpperCase(); + if (!symbol) return null; + const livePrice = Number(apiServer.getState().symbols?.[symbol]?.price || 0); + return Number.isFinite(livePrice) && livePrice > 0 ? livePrice : null; + }; + const bindSimpleBoughtPosition = async (entry: ManualEntryRecord, ctx: UserContext): Promise => { + const linkedTradeId = String(entry.linked_trade_id || '').trim(); + const activePosition = linkedTradeId + ? ctx.executor.getActivePosition(entry.symbol, linkedTradeId) + : ctx.executor.getActivePosition(entry.symbol); + if (!activePosition) { + return false; + } + + await saveManualEntryForUser(entry.user_id, { + ...entry, + profile_id: entry.profile_id || ctx.profileId, + linked_trade_id: activePosition.tradeId || entry.linked_trade_id, + entry_price: activePosition.entryPrice, + filled_quantity: activePosition.size, + buy_time: entry.buy_time || new Date().toISOString(), + status: 'simple_bought', + active: true, + }); + return true; + }; + let simpleWorkerRunning = false; + const runSimpleWorker = async () => { + if (simpleWorkerRunning) return; + simpleWorkerRunning = true; + try { + const entries = (await listManualEntries()).filter((entry) => entry.active && isSimpleWorkflowEntry(entry)); + for (const entry of entries) { + const symbol = String(entry.symbol || '').trim().toUpperCase(); + if (!symbol) continue; + + const ctx = getSimpleWorkerContext(entry); + if (!ctx) continue; + + const currentPrice = resolveSimpleMarketPrice(entry); + if (!(currentPrice && currentPrice > 0)) continue; + + if (shouldArmSimpleBuy(entry)) { + const triggerPrice = computeSimpleBuyTriggerPrice(entry); + const desiredQty = toPositiveNumber(entry.quantity); + if (!triggerPrice || !desiredQty) continue; + if (currentPrice > triggerPrice) continue; + + const result = await ctx.manualTrader.executeRequest( + symbol, + 'buy', + desiredQty, + 'limit', + triggerPrice, + currentPrice, + entry.user_id + ); + if (!result.success) { + logger.warn(`[SimpleWorker] Buy trigger failed for ${symbol}: ${result.error || 'unknown error'}`); + continue; + } + + await saveManualEntryForUser(entry.user_id, { + ...entry, + profile_id: entry.profile_id || ctx.profileId, + filled_quantity: result.adjustedQty ?? entry.filled_quantity, + buy_time: new Date().toISOString(), + status: 'simple_entry_submitted', + active: true, + }); + continue; + } + + if (isSimpleSubmittedStatus(entry.status)) { + await bindSimpleBoughtPosition(entry, ctx); + continue; + } + + if (!(isSimpleBoughtStatus(entry.status) || isSimpleSellArmedStatus(entry.status) || isSimpleExitSubmittedStatus(entry.status))) { + continue; + } + + const linkedTradeId = String(entry.linked_trade_id || '').trim(); + const activePosition = linkedTradeId + ? ctx.executor.getActivePosition(symbol, linkedTradeId) + : ctx.executor.getActivePosition(symbol); + + if (isSimpleExitSubmittedStatus(entry.status)) { + if (!activePosition) { + await saveManualEntryForUser(entry.user_id, { + ...entry, + active: false, + status: 'sellCompleted', + sell_time: entry.sell_time || new Date().toISOString(), + sell_price: currentPrice, + }); + } + continue; + } + + if (!activePosition) { + if (isSimpleBoughtStatus(entry.status)) { + await bindSimpleBoughtPosition(entry, ctx); + } + continue; + } + + const activeEntryPrice = toPositiveNumber(entry.entry_price) || activePosition.entryPrice; + const targetPrice = computeSimpleProfitTargetPrice(activeEntryPrice, entry); + if (!targetPrice || currentPrice < targetPrice) continue; + + const exitResult = await ctx.manualTrader.executeExit(symbol, currentPrice, 'Simple target hit', linkedTradeId || undefined); + if (!exitResult.success) { + logger.warn(`[SimpleWorker] Exit trigger failed for ${symbol}: ${exitResult.error || 'unknown error'}`); + continue; + } + + await saveManualEntryForUser(entry.user_id, { + ...entry, + profile_id: entry.profile_id || ctx.profileId, + linked_trade_id: linkedTradeId || activePosition.tradeId, + entry_price: activeEntryPrice, + filled_quantity: entry.filled_quantity ?? activePosition.size, + status: 'simple_exit_submitted', + active: true, + }); + } + } catch (error: any) { + logger.error(`[SimpleWorker] Error during simple setup scan: ${error.message || error}`); + } finally { + simpleWorkerRunning = false; + } + }; const buildOrderSyncHandler = ( executor: TradeExecutor, @@ -512,6 +733,14 @@ async function main() { logger.info(`Monitoring ${collectMonitoredSymbols().join(', ')} via DATA=${config.DATA_PROVIDER} and EXECUTION=${config.EXECUTION_PROVIDER}`); + void runSimpleWorker(); + const simpleWorkerTimer = setInterval(() => { + void runSimpleWorker(); + }, SIMPLE_WORKER_INTERVAL_MS); + if (typeof simpleWorkerTimer.unref === 'function') { + simpleWorkerTimer.unref(); + } + // --- State variables for periodic alerts (Global Signal State) --- const assetState = new Map; + if (String(payload.workflow_type || '').trim().toLowerCase() === 'simple') { + const simpleProfile = await ensureSimpleAutoProfileForUser(authUserId, String(payload.symbol || '')); + payload.profile_id = simpleProfile.id; + payload.label = payload.label || 'Simple'; + } + const entry = await saveManualEntryForUser(authUserId, payload); res.json({ entry }); } catch (error: any) { res.status(400).json({ error: `Failed to update manual entry: ${error.message}` }); diff --git a/backend/src/services/manualEntryRepository.ts b/backend/src/services/manualEntryRepository.ts index 4488ff6..2bd0126 100644 --- a/backend/src/services/manualEntryRepository.ts +++ b/backend/src/services/manualEntryRepository.ts @@ -17,6 +17,7 @@ export interface ManualEntryRecord { symbol: string; active: boolean; user_id: string; + profile_id?: string | null; buy_price?: number | null; sell_price?: number | null; buy_time?: string | null; @@ -29,8 +30,14 @@ export interface ManualEntryRecord { is_real_trade: boolean; label?: string | null; entry_price?: number | null; + reference_price?: number | null; gain_threshold_for_sell?: number | null; drop_threshold_for_buy?: number | null; + workflow_type?: string | null; + simple_side?: string | null; + drop_trigger_mode?: string | null; + profit_target_mode?: string | null; + linked_trade_id?: string | null; } type ManualEntryDocument = ManualEntryRecord & { @@ -61,6 +68,7 @@ function normalizeEntry(userId: string, input: Partial, exist symbol: String(input.symbol || existing?.symbol || '').trim(), active: Boolean(input.active ?? existing?.active ?? true), user_id: userId, + profile_id: normalizeNullableString(input.profile_id ?? existing?.profile_id), buy_price: normalizeNullableNumber(input.buy_price ?? existing?.buy_price), sell_price: normalizeNullableNumber(input.sell_price ?? existing?.sell_price), buy_time: normalizeNullableString(input.buy_time ?? existing?.buy_time), @@ -73,8 +81,14 @@ function normalizeEntry(userId: string, input: Partial, exist is_real_trade: Boolean(input.is_real_trade ?? existing?.is_real_trade ?? false), label: normalizeNullableString(input.label ?? existing?.label), entry_price: normalizeNullableNumber(input.entry_price ?? existing?.entry_price), + 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), + 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), }; } diff --git a/backend/src/services/profileRepository.ts b/backend/src/services/profileRepository.ts index b778116..aededca 100644 --- a/backend/src/services/profileRepository.ts +++ b/backend/src/services/profileRepository.ts @@ -34,6 +34,9 @@ export interface TradeProfileRecord { updated_at?: string; } +export const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile'; +const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase(); + export interface TradeProfileCapitalSummary { allocatedCapital: number; isActive: boolean; @@ -117,6 +120,37 @@ function buildDefaultTradeProfile(userId: string): TradeProfileRecord { }; } +function parseProfileSymbols(symbolsRaw?: string | null): string[] { + return String(symbolsRaw || '') + .split(',') + .map((value) => value.trim().toUpperCase()) + .filter(Boolean); +} + +function buildSimpleAutoProfile(userId: string, symbol?: string): TradeProfileRecord { + const normalizedSymbol = String(symbol || '').trim().toUpperCase(); + const timestamp = new Date().toISOString(); + return { + id: randomUUID(), + user_id: userId, + name: SIMPLE_AUTO_PROFILE_NAME, + allocated_capital: 1000, + risk_per_trade_percent: 1, + symbols: normalizedSymbol || 'AAPL', + is_active: true, + strategy_config: { + mode: 'simple-auto', + source: 'simple-tab', + execution: { + orderType: 'limit', + entryMode: 'manual', + }, + }, + created_at: timestamp, + updated_at: timestamp, + }; +} + function normalizeTradingUserProfile( row: Partial | null | undefined, fallbackUserId?: string @@ -491,6 +525,33 @@ export async function ensureDefaultTradeProfileForUser(userId: string): Promise< return [created]; } +export async function ensureSimpleAutoProfileForUser(userId: string, symbol?: string): Promise { + const normalizedSymbol = String(symbol || '').trim().toUpperCase(); + const profiles = await listTradeProfilesForUser(userId); + const existing = profiles.find((profile) => String(profile.name || '').trim().toLowerCase() === SIMPLE_AUTO_PROFILE_KEY); + + if (!existing) { + return saveTradeProfileForUser(buildSimpleAutoProfile(userId, normalizedSymbol), userId); + } + + const symbolSet = new Set(parseProfileSymbols(existing.symbols)); + if (normalizedSymbol) { + symbolSet.add(normalizedSymbol); + } + const nextSymbols = Array.from(symbolSet).join(', '); + const nextIsActive = true; + const didChange = existing.symbols !== nextSymbols || !existing.is_active; + if (!didChange) { + return existing; + } + + return saveTradeProfileForUser({ + ...existing, + symbols: nextSymbols, + is_active: nextIsActive, + }, userId); +} + export async function saveTradeProfileForUser( input: Partial, userId: string diff --git a/web/src/lib/manualEntriesApi.ts b/web/src/lib/manualEntriesApi.ts index de65b40..4d39176 100644 --- a/web/src/lib/manualEntriesApi.ts +++ b/web/src/lib/manualEntriesApi.ts @@ -7,6 +7,7 @@ export interface ManualEntryPayload { symbol: string; active: boolean; user_id?: string; + profile_id?: string | null; buy_price?: number | null; sell_price?: number | null; buy_time?: string | null; @@ -19,8 +20,14 @@ export interface ManualEntryPayload { is_real_trade: boolean; label?: string | null; entry_price?: number | null; + reference_price?: number | null; gain_threshold_for_sell?: number | null; drop_threshold_for_buy?: number | null; + workflow_type?: string | null; + simple_side?: string | null; + drop_trigger_mode?: string | null; + profit_target_mode?: string | null; + linked_trade_id?: string | null; } async function getAccessToken(): Promise { diff --git a/web/src/views/SimpleView.test.ts b/web/src/views/SimpleView.test.ts index 6587d3d..dbe5509 100644 --- a/web/src/views/SimpleView.test.ts +++ b/web/src/views/SimpleView.test.ts @@ -1,96 +1,113 @@ import { describe, expect, it } from 'vitest'; -import { - buildSimpleAutoProfilePayload, - buildSimpleTradePayload, - computeProtectionPrices, - SIMPLE_AUTO_PROFILE_NAME, -} from './SimpleView'; +import { buildSimpleSetupPayload } from './SimpleView'; describe('SimpleView helpers', () => { - it('computes buy-side protection prices from reference price', () => { - expect( - computeProtectionPrices({ + it('builds a buy dip setup payload with dollar drop and percent profit target', () => { + const payload = buildSimpleSetupPayload({ + draft: { + symbol: 'aapl', side: 'buy', - referencePrice: 100, - stopLossPercent: 5, - takeProfitPercent: 8, - }), - ).toEqual({ sl: 95, tp: 108 }); - }); - - it('computes sell-side protection prices from reference price', () => { - expect( - computeProtectionPrices({ - side: 'sell', - referencePrice: 100, - stopLossPercent: 5, - takeProfitPercent: 8, - }), - ).toEqual({ sl: 105, tp: 92 }); - }); - - it('builds a market buy trade payload from market price', () => { - const payload = buildSimpleTradePayload({ - symbol: 'aapl', - side: 'buy', - orderType: 'market', - quantity: '5', - limitPrice: '', - currentMarketPrice: '210.25', - stopLossPercent: '4', - takeProfitPercent: '10', - isCrypto: false, - }); - - expect(payload).toEqual({ - symbol: 'AAPL', - side: 'buy', - qty: 5, - type: 'market', - sl: 201.84, - tp: 231.275, - }); - }); - - it('builds a limit sell trade payload from limit price', () => { - const payload = buildSimpleTradePayload({ - symbol: 'btc/usd', - side: 'sell', - orderType: 'limit', - quantity: '0.25', - limitPrice: '64000', - currentMarketPrice: '65000', - stopLossPercent: '3', - takeProfitPercent: '6', - isCrypto: true, - }); - - expect(payload).toEqual({ - symbol: 'BTC/USD', - side: 'sell', - qty: 0.25, - type: 'limit', - price: 64000, - sl: 65920, - tp: 60160, - }); - }); - - it('builds the dedicated simple auto profile payload', () => { - expect(buildSimpleAutoProfilePayload('msft')).toEqual({ - name: SIMPLE_AUTO_PROFILE_NAME, - allocated_capital: 1000, - risk_per_trade_percent: 1, - symbols: 'MSFT', - is_active: true, - strategy_config: { - mode: 'simple-auto', - source: 'simple-tab', - execution: { - orderType: 'market', - entryMode: 'manual', - }, + quantity: '5', + currentMarketPrice: '210.25', + dropMode: 'dollar', + dropValue: '12', + profitMode: 'percent', + profitValue: '8', + notes: 'Long-term compounder', }, }); + + expect(payload).toEqual({ + stock_instance_id: undefined, + symbol: 'AAPL', + active: true, + status: 'simple_armed_buy', + is_crypto: false, + is_real_trade: false, + label: 'Simple', + quantity: 5, + filled_quantity: null, + notes: 'Long-term compounder', + entry_price: null, + reference_price: 210.25, + gain_threshold_for_sell: 8, + drop_threshold_for_buy: 12, + workflow_type: 'simple', + simple_side: 'buy', + drop_trigger_mode: 'dollar', + profit_target_mode: 'percent', + linked_trade_id: null, + profile_id: null, + buy_price: null, + sell_price: null, + buy_time: null, + sell_time: null, + }); + }); + + it('builds a sell setup payload from an existing holding', () => { + const payload = buildSimpleSetupPayload({ + draft: { + symbol: 'msft', + side: 'sell', + quantity: '1', + currentMarketPrice: '420.50', + dropMode: 'percent', + dropValue: '', + profitMode: 'dollar', + profitValue: '15', + notes: '', + }, + holding: { + symbol: 'MSFT', + size: 10, + entryPrice: 380.25, + profileId: 'simple-profile', + tradeId: 'TRD-123', + }, + }); + + expect(payload).toEqual({ + stock_instance_id: undefined, + symbol: 'MSFT', + active: true, + status: 'simple_armed_sell', + is_crypto: false, + is_real_trade: false, + label: 'Simple', + quantity: 10, + filled_quantity: 10, + notes: null, + entry_price: 380.25, + reference_price: 420.5, + gain_threshold_for_sell: 15, + drop_threshold_for_buy: null, + workflow_type: 'simple', + simple_side: 'sell', + drop_trigger_mode: null, + profit_target_mode: 'dollar', + linked_trade_id: 'TRD-123', + profile_id: 'simple-profile', + 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', + quantity: '5', + currentMarketPrice: '900', + dropMode: 'percent', + dropValue: '', + profitMode: 'percent', + profitValue: '6', + notes: '', + }, + })).toThrow('Sell setups require an existing Simple holding for this symbol.'); }); }); diff --git a/web/src/views/SimpleView.tsx b/web/src/views/SimpleView.tsx index fbb92e9..e2ad9ea 100644 --- a/web/src/views/SimpleView.tsx +++ b/web/src/views/SimpleView.tsx @@ -1,60 +1,55 @@ import { useEffect, useMemo, useState } from 'react'; -import { RefreshCw, Target } from 'lucide-react'; +import type { FormEvent } from 'react'; +import { Pencil, RefreshCw, Target, Trash2 } from 'lucide-react'; import { useAppContext } from '../context/AppContext'; import { fetchChartBars } from '../lib/marketApi'; -import { getPlatformAccessToken } from '../lib/authSession'; -import { tradingRuntime } from '../lib/runtime'; -import { createRequestId } from '../../../shared/request-id.js'; import { - createTradeProfile, - fetchTradeProfiles, - setTradeProfileActive, - type TradeProfilePayload, -} from '../lib/profileApi'; + createManualEntry, + deleteManualEntry, + fetchManualEntries, + updateManualEntry, + type ManualEntryPayload, +} from '../lib/manualEntriesApi'; +import { fetchTradeProfiles, type TradeProfilePayload } from '../lib/profileApi'; type SimpleSide = 'buy' | 'sell'; -type SimpleOrderType = 'market' | 'limit'; +type TriggerMode = 'dollar' | 'percent'; -type SimpleTradeDraft = { +type SimpleSetupDraft = { symbol: string; side: SimpleSide; - orderType: SimpleOrderType; quantity: string; - limitPrice: string; currentMarketPrice: string; - stopLossPercent: string; - takeProfitPercent: string; - isCrypto: boolean; + dropMode: TriggerMode; + dropValue: string; + profitMode: TriggerMode; + profitValue: string; + notes: string; }; -type SimpleTradePayload = { - profile_id?: string; +type SimpleHolding = { symbol: string; - side: SimpleSide; - qty: number; - type: SimpleOrderType; - price?: number; - sl?: number; - tp?: number; + size: number; + entryPrice: number; + profileId?: string; + tradeId?: string; }; -const DEFAULT_DRAFT: SimpleTradeDraft = { +const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile'; +const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase(); + +const DEFAULT_DRAFT: SimpleSetupDraft = { symbol: '', side: 'buy', - orderType: 'market', quantity: '', - limitPrice: '', currentMarketPrice: '', - stopLossPercent: '', - takeProfitPercent: '', - isCrypto: false, + dropMode: 'percent', + dropValue: '', + profitMode: 'percent', + profitValue: '', + notes: '', }; -export const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile'; -const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase(); -const SIMPLE_PROFILE_RETRY_DELAY_MS = 2000; -const SIMPLE_PROFILE_RETRY_ATTEMPTS = 8; - function parsePositiveNumber(value: string): number | null { const trimmed = value.trim(); if (!trimmed) return null; @@ -66,126 +61,223 @@ function roundPrice(value: number): number { return Number(value.toFixed(4)); } -function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - function matchesSimpleAutoProfile(profile: Pick | null | undefined) { return String(profile?.name || '').trim().toLowerCase() === SIMPLE_AUTO_PROFILE_KEY; } -export function buildSimpleAutoProfilePayload(symbol: string): TradeProfilePayload { - const normalizedSymbol = symbol.trim().toUpperCase(); - - return { - name: SIMPLE_AUTO_PROFILE_NAME, - allocated_capital: 1000, - risk_per_trade_percent: 1, - symbols: normalizedSymbol || 'AAPL', - is_active: true, - strategy_config: { - mode: 'simple-auto', - source: 'simple-tab', - execution: { - orderType: 'market', - entryMode: 'manual', - }, - }, - }; +function normalizeSetupSide(value: unknown): SimpleSide { + return String(value || '').trim().toLowerCase() === 'sell' ? 'sell' : 'buy'; } -export function computeProtectionPrices(input: { - side: SimpleSide; - referencePrice: number; - stopLossPercent?: number | null; - takeProfitPercent?: number | null; -}) { - const { side, referencePrice, stopLossPercent, takeProfitPercent } = input; - let sl: number | undefined; - let tp: number | undefined; - - if (stopLossPercent && stopLossPercent > 0) { - sl = side === 'buy' - ? roundPrice(referencePrice * (1 - stopLossPercent / 100)) - : roundPrice(referencePrice * (1 + stopLossPercent / 100)); - } - - if (takeProfitPercent && takeProfitPercent > 0) { - tp = side === 'buy' - ? roundPrice(referencePrice * (1 + takeProfitPercent / 100)) - : roundPrice(referencePrice * (1 - takeProfitPercent / 100)); - } - - return { sl, tp }; +function normalizeMode(value: unknown, fallback: TriggerMode = 'percent'): TriggerMode { + return String(value || '').trim().toLowerCase() === 'dollar' ? 'dollar' : fallback; } -export function buildSimpleTradePayload(draft: SimpleTradeDraft): SimpleTradePayload { - const symbol = draft.symbol.trim().toUpperCase(); +function computeBuyTriggerPrice(draft: SimpleSetupDraft): number | null { + const currentMarketPrice = parsePositiveNumber(draft.currentMarketPrice); + const dropValue = parsePositiveNumber(draft.dropValue); + if (!currentMarketPrice || !dropValue) 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; +}): ManualEntryPayload { + const symbol = input.draft.symbol.trim().toUpperCase(); if (!symbol) { throw new Error('Symbol is required'); } - const qty = parsePositiveNumber(draft.quantity); - if (qty === null) { - throw new Error('Quantity is required'); - } - - const price = draft.orderType === 'limit' ? parsePositiveNumber(draft.limitPrice) : null; - if (draft.orderType === 'limit' && price === null) { - throw new Error('Limit price is required'); - } - - const referencePrice = price ?? parsePositiveNumber(draft.currentMarketPrice); - if (referencePrice === null) { + const currentMarketPrice = parsePositiveNumber(input.draft.currentMarketPrice); + if (!currentMarketPrice) { throw new Error('Current market price is unavailable. Refresh market data and try again.'); } - const stopLossPercent = parsePositiveNumber(draft.stopLossPercent); - const takeProfitPercent = parsePositiveNumber(draft.takeProfitPercent); - const { sl, tp } = computeProtectionPrices({ - side: draft.side, - referencePrice, - stopLossPercent, - takeProfitPercent, - }); - - const payload: SimpleTradePayload = { - symbol, - side: draft.side, - qty, - type: draft.orderType, - sl, - tp, - }; - - if (price !== null) { - payload.price = price; + const quantity = parsePositiveNumber(input.draft.quantity); + if (!quantity) { + throw new Error('Quantity is required'); } - return payload; + 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; + + if (side === 'sell' && !holding) { + throw new Error('Sell setups require an existing Simple holding for this symbol.'); + } + + if (side === 'buy' && !parsePositiveNumber(input.draft.dropValue)) { + throw new Error('Drop trigger is required for buy setups'); + } + + 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 : 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' ? parsePositiveNumber(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, + buy_price: null, + sell_price: null, + buy_time: null, + sell_time: null, + }; } -function buildExecutionPreview(payload: SimpleTradePayload | null) { - if (!payload) return null; +function buildPreviewText(draft: SimpleSetupDraft, holding: SimpleHolding | null): string | null { + const symbol = draft.symbol.trim().toUpperCase(); + if (!symbol) return null; - const verb = payload.side === 'buy' ? 'BUY' : 'SELL'; - const orderText = payload.type === 'market' - ? `${verb} ${payload.symbol} now at market` - : `${verb} ${payload.symbol} with a limit at ${Number(payload.price).toFixed(4)}`; + 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 extras = [ - `Qty ${payload.qty}`, - payload.sl ? `SL ${payload.sl.toFixed(4)}` : null, - payload.tp ? `TP ${payload.tp.toFixed(4)}` : null, - ].filter(Boolean); + const dropText = 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`; - return `${orderText} · ${extras.join(' · ')}`; + return [ + `Buy ${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 a Simple holding already exists.`; + } + + 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 buildDraftFromEntry(entry: ManualEntryPayload): SimpleSetupDraft { + return { + symbol: String(entry.symbol || '').trim().toUpperCase(), + side: normalizeSetupSide(entry.simple_side), + quantity: entry.quantity ? String(entry.quantity) : '', + currentMarketPrice: entry.reference_price ? Number(entry.reference_price).toFixed(4) : '', + dropMode: normalizeMode(entry.drop_trigger_mode, 'percent'), + dropValue: entry.drop_threshold_for_buy ? String(entry.drop_threshold_for_buy) : '', + profitMode: normalizeMode(entry.profit_target_mode, 'percent'), + profitValue: entry.gain_threshold_for_sell ? String(entry.gain_threshold_for_sell) : '', + notes: String(entry.notes || ''), + }; +} + +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 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 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); + + if (side === 'buy') { + const triggerPrice = computeBuyTriggerPrice(buildDraftFromEntry(entry)); + const dropText = dropMode === 'dollar' + ? `$${dropValue.toFixed(2)} below` + : `${dropValue}% below`; + 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}.`; + } + + 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)})` : ''}.`; } export function SimpleView() { const { botState } = useAppContext(); const [profiles, setProfiles] = useState([]); - const [draft, setDraft] = useState(DEFAULT_DRAFT); + const [savedSetups, setSavedSetups] = useState([]); + const [editingSetupId, setEditingSetupId] = useState(null); + const [draft, setDraft] = useState(DEFAULT_DRAFT); const [submitting, setSubmitting] = useState(false); const [loadingPrice, setLoadingPrice] = useState(false); const [message, setMessage] = useState(null); @@ -193,28 +285,49 @@ export function SimpleView() { const normalizedSymbol = draft.symbol.trim().toUpperCase(); const livePrice = normalizedSymbol ? Number(botState.symbols?.[normalizedSymbol]?.price || 0) : 0; - const resolvedMarketPrice = parsePositiveNumber(draft.currentMarketPrice); - const marketPriceSource = livePrice > 0 ? 'live' : (resolvedMarketPrice !== null ? 'recent_close' : null); const simpleAutoProfile = useMemo( () => profiles.find((profile) => matchesSimpleAutoProfile(profile)) || null, [profiles], ); + const simpleHoldings = useMemo(() => { + const simpleProfileId = simpleAutoProfile?.id; + return botState.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 matchingHolding = useMemo( + () => simpleHoldings.find((holding) => holding.symbol === normalizedSymbol) || null, + [simpleHoldings, normalizedSymbol], + ); + useEffect(() => { let cancelled = false; - async function loadProfiles() { + async function loadData() { try { - const rows = await fetchTradeProfiles(); + const [profileRows, entryRows] = await Promise.all([ + fetchTradeProfiles(), + fetchManualEntries(), + ]); if (cancelled) return; - setProfiles(rows); + setProfiles(profileRows); + setSavedSetups(normalizeSimpleEntries(entryRows)); } catch (err: any) { if (cancelled) return; - setError(err?.message ?? 'Failed to load trade profiles'); + setError(err?.message ?? 'Failed to load Simple setups'); } } - void loadProfiles(); + void loadData(); return () => { cancelled = true; }; @@ -222,7 +335,7 @@ export function SimpleView() { useEffect(() => { if (!livePrice) return; - setDraft(prev => ( + setDraft((prev) => ( prev.currentMarketPrice === livePrice.toFixed(4) ? prev : { ...prev, currentMarketPrice: livePrice.toFixed(4) } @@ -237,88 +350,43 @@ export function SimpleView() { void handleLoadMarketPrice(); }, [normalizedSymbol, livePrice]); - const payloadPreview = useMemo(() => { - try { - return buildSimpleTradePayload(draft); - } catch { - return null; - } - }, [draft]); - - const executionPreview = useMemo( - () => buildExecutionPreview(payloadPreview), - [payloadPreview], + const previewText = useMemo( + () => buildPreviewText(draft, draft.side === 'sell' ? matchingHolding : null), + [draft, matchingHolding], ); - function updateDraft(key: K, value: SimpleTradeDraft[K]) { - setDraft(prev => ({ ...prev, [key]: value })); + function updateDraft(key: K, value: SimpleSetupDraft[K]) { + setDraft((prev) => ({ ...prev, [key]: value })); } - async function ensureSimpleAutoProfile(symbol: string) { - const refreshProfiles = async () => { - const rows = await fetchTradeProfiles(); - setProfiles(rows); - return rows; - }; - - let rows = await refreshProfiles(); - let profile = rows.find((row) => matchesSimpleAutoProfile(row)) || null; - - if (!profile) { - profile = await createTradeProfile(buildSimpleAutoProfilePayload(symbol)); - rows = await refreshProfiles(); - profile = rows.find((row) => row.id === profile?.id) || profile; - } - - if (!profile?.is_active && profile?.id) { - profile = await setTradeProfileActive(profile.id, true); - rows = await refreshProfiles(); - profile = rows.find((row) => row.id === profile?.id) || profile; - } - - if (!profile?.id) { - throw new Error('Failed to prepare the Simple Auto Profile'); - } - - return profile; - } - - async function submitTradeRequest(accessToken: string, payload: SimpleTradePayload) { - const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/trade`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, - 'x-request-id': createRequestId('web-simple-trade'), - }, - body: JSON.stringify(payload), - }); - - const result = await response.json().catch(() => ({})); - if (!response.ok || !result?.success) { - throw new Error(result?.error || `Trade request failed (${response.status})`); - } - - return result; + async function refreshSetupList() { + const [profileRows, entryRows] = await Promise.all([ + fetchTradeProfiles(), + fetchManualEntries(), + ]); + setProfiles(profileRows); + setSavedSetups(normalizeSimpleEntries(entryRows)); } async function handleLoadMarketPrice() { - if (!normalizedSymbol) { - setError('Enter a symbol first'); - return; - } - + if (!normalizedSymbol) return; setLoadingPrice(true); setError(null); setMessage(null); + try { - const bars = await fetchChartBars(normalizedSymbol, '1D'); - const latestClose = Number(bars[bars.length - 1]?.close || 0); - if (!Number.isFinite(latestClose) || latestClose <= 0) { - throw new Error(`No recent market price found for ${normalizedSymbol}`); + if (livePrice > 0) { + updateDraft('currentMarketPrice', livePrice.toFixed(4)); + return; + } + + const bars = await fetchChartBars(normalizedSymbol, '1D'); + const lastClose = Number(bars?.[bars.length - 1]?.close || 0); + if (Number.isFinite(lastClose) && lastClose > 0) { + updateDraft('currentMarketPrice', lastClose.toFixed(4)); + } else { + throw new Error('No recent market price available'); } - updateDraft('currentMarketPrice', latestClose.toFixed(4)); - setMessage(`Loaded market price for ${normalizedSymbol}`); } catch (err: any) { setError(err?.message ?? 'Failed to load market price'); } finally { @@ -326,281 +394,366 @@ export function SimpleView() { } } - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - + async function handleSubmit(e: FormEvent) { + e.preventDefault(); setSubmitting(true); setError(null); setMessage(null); try { - const payload = buildSimpleTradePayload(draft); - const simpleProfile = await ensureSimpleAutoProfile(payload.symbol); - payload.profile_id = String(simpleProfile.id); - const accessToken = await getPlatformAccessToken(); - let result: any = null; + const payload = buildSimpleSetupPayload({ + draft, + existingId: editingSetupId || undefined, + holding: draft.side === 'sell' ? matchingHolding : null, + }); - for (let attempt = 1; attempt <= SIMPLE_PROFILE_RETRY_ATTEMPTS; attempt += 1) { - try { - result = await submitTradeRequest(accessToken, payload); - break; - } catch (err: any) { - const message = String(err?.message || ''); - const isManagerWarmup = message.includes('No Manual Trader available'); - if (!isManagerWarmup || attempt === SIMPLE_PROFILE_RETRY_ATTEMPTS) { - throw err; - } - setMessage(`Preparing ${SIMPLE_AUTO_PROFILE_NAME}… retrying order submission (${attempt}/${SIMPLE_PROFILE_RETRY_ATTEMPTS - 1})`); - await sleep(SIMPLE_PROFILE_RETRY_DELAY_MS); - } + if (editingSetupId) { + await updateManualEntry(editingSetupId, payload); + setMessage(`Updated ${normalizedSymbol} Simple setup.`); + } else { + await createManualEntry({ + ...payload, + stock_instance_id: crypto.randomUUID(), + }); + setMessage(`Saved ${normalizedSymbol} Simple setup.`); } - setMessage(`Order submitted via ${SIMPLE_AUTO_PROFILE_NAME}${result.orderId ? ` · ${result.orderId}` : ''}`); - setDraft(prev => ({ + await refreshSetupList(); + setEditingSetupId(null); + setDraft({ ...DEFAULT_DRAFT, - symbol: prev.symbol, - isCrypto: prev.isCrypto, - currentMarketPrice: prev.currentMarketPrice, - })); + currentMarketPrice: draft.currentMarketPrice, + }); } catch (err: any) { - setError(err?.message ?? 'Failed to submit trade'); + setError(err?.message ?? 'Failed to save Simple setup'); } finally { setSubmitting(false); } } - return ( -
-

Simple

+ function handleEdit(entry: ManualEntryPayload) { + setEditingSetupId(String(entry.stock_instance_id || '')); + setDraft(buildDraftFromEntry(entry)); + setMessage(null); + setError(null); + } -
-
+ async function handleDelete(entryId: string) { + if (!window.confirm('Delete this Simple setup?')) return; + try { + await deleteManualEntry(entryId); + if (editingSetupId === entryId) { + setEditingSetupId(null); + setDraft(DEFAULT_DRAFT); + } + await refreshSetupList(); + } catch (err: any) { + setError(err?.message ?? 'Failed to delete Simple setup'); + } + } + + const saveButtonLabel = editingSetupId ? 'Update setup' : 'Save setup'; + const saveButtonDisabled = submitting || loadingPrice || (draft.side === 'sell' && !matchingHolding); + + return ( +
+
+
+
+ +
-
- Direct Execution -
-
- Submit a simple buy or sell without the full strategy builder -
-
- This page sends direct orders through one dedicated auto-created profile. Your first order prepares that profile automatically, and every later Simple order reuses it. -
+

Simple

+

+ Save dip-buy setups for selective long-term names. Buy after a dollar or percent drop, + then keep a profit exit armed so the worst-case path is holding a name you already like. +

+
-
-
- - - - - - - +
+
+
+
+

+ {editingSetupId ? 'Edit setup' : 'New setup'} +

+

+ Saved trigger workflow, not an immediate broker order +

+
+
-
-
- - +
+
+

Saved setups

+

+ One dedicated Simple auto profile routes every triggered order +

+
+ +
+ {savedSetups.length === 0 && ( +
+ No Simple setups saved yet. +
+ )} + + {savedSetups.map((entry) => { + const entryId = String(entry.stock_instance_id || ''); + const side = normalizeSetupSide(entry.simple_side); + const isEditing = editingSetupId === entryId; + return ( +
+
+
+
+

{entry.symbol}

+ + {side} + +
+

{describeSavedSetup(entry)}

+
+
+ + +
+
+ +
+ + {formatSetupStatus(entry.status)} + + {entry.reference_price ? ( + + Ref {Number(entry.reference_price).toFixed(4)} + + ) : null} + {entry.entry_price ? ( + + Entry {Number(entry.entry_price).toFixed(4)} + + ) : null} + {entry.linked_trade_id ? ( + + Trade linked + + ) : null} +
+
+ ); + })} +
+
);