diff --git a/web/src/views/SimpleView.test.ts b/web/src/views/SimpleView.test.ts index 81b6ba0..87a09b4 100644 --- a/web/src/views/SimpleView.test.ts +++ b/web/src/views/SimpleView.test.ts @@ -1,59 +1,73 @@ import { describe, expect, it } from 'vitest'; -import { buildSimpleEntryPayload, computeSimpleTriggerPrice } from './SimpleView'; +import { buildSimpleTradePayload, computeProtectionPrices } from './SimpleView'; describe('SimpleView helpers', () => { - it('computes a buy trigger from current market drop percent', () => { + it('computes buy-side protection prices from reference price', () => { expect( - computeSimpleTriggerPrice({ - triggerMode: 'drop_percent', - targetPrice: '', - purchasePrice: '', - currentMarketPrice: '100', - percentValue: '7.5', + computeProtectionPrices({ + side: 'buy', + referencePrice: 100, + stopLossPercent: 5, + takeProfitPercent: 8, }), - ).toBe(92.5); + ).toEqual({ sl: 95, tp: 108 }); }); - it('builds a sell profit rule payload from purchase price', () => { - const payload = buildSimpleEntryPayload('user-1', { + 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: 'sell', - triggerMode: 'profit_percent', + side: 'buy', + orderType: 'market', quantity: '5', - targetPrice: '', - purchasePrice: '200', - currentMarketPrice: '', - percentValue: '10', + limitPrice: '', + currentMarketPrice: '210.25', + stopLossPercent: '4', + takeProfitPercent: '10', isCrypto: false, - notes: 'Trim on strength', }); - expect(payload.symbol).toBe('AAPL'); - expect(payload.sell_price).toBe(220); - expect(payload.buy_price).toBe(200); - expect(payload.gain_threshold_for_sell).toBe(10); - expect(payload.label).toBe('SIMPLE SELL'); - expect(payload.notes).toContain('SELL AAPL at 10.00% profit'); + expect(payload).toEqual({ + symbol: 'AAPL', + side: 'buy', + qty: 5, + type: 'market', + sl: 201.84, + tp: 231.275, + }); }); - it('builds a buy target price payload', () => { - const payload = buildSimpleEntryPayload('user-1', { + it('builds a limit sell trade payload from limit price', () => { + const payload = buildSimpleTradePayload({ symbol: 'btc/usd', - side: 'buy', - triggerMode: 'target_price', - quantity: '', - targetPrice: '64000', - purchasePrice: '', - currentMarketPrice: '', - percentValue: '', + side: 'sell', + orderType: 'limit', + quantity: '0.25', + limitPrice: '64000', + currentMarketPrice: '65000', + stopLossPercent: '3', + takeProfitPercent: '6', isCrypto: true, - notes: '', }); - expect(payload.symbol).toBe('BTC/USD'); - expect(payload.buy_price).toBe(64000); - expect(payload.sell_price).toBeNull(); - expect(payload.label).toBe('SIMPLE BUY'); - expect(payload.is_crypto).toBe(true); + expect(payload).toEqual({ + symbol: 'BTC/USD', + side: 'sell', + qty: 0.25, + type: 'limit', + price: 64000, + sl: 65920, + tp: 60160, + }); }); }); diff --git a/web/src/views/SimpleView.tsx b/web/src/views/SimpleView.tsx index b7d0c9b..2e8a899 100644 --- a/web/src/views/SimpleView.tsx +++ b/web/src/views/SimpleView.tsx @@ -1,51 +1,46 @@ import { useEffect, useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { RefreshCw, Target, Trash2 } from 'lucide-react'; -import { useAuth } from '../components/AuthContext'; +import { RefreshCw, Target } from 'lucide-react'; import { useAppContext } from '../context/AppContext'; import { fetchChartBars } from '../lib/marketApi'; -import { - createManualEntry, - deleteManualEntry, - fetchManualEntries, - type ManualEntryPayload, -} from '../lib/manualEntriesApi'; +import { getPlatformAccessToken } from '../lib/authSession'; +import { tradingRuntime } from '../lib/runtime'; +import { createRequestId } from '../../../shared/request-id.js'; type SimpleSide = 'buy' | 'sell'; -type SimpleTriggerMode = 'target_price' | 'profit_percent' | 'drop_percent'; +type SimpleOrderType = 'market' | 'limit'; -type SimpleRuleDraft = { +type SimpleTradeDraft = { symbol: string; side: SimpleSide; - triggerMode: SimpleTriggerMode; + orderType: SimpleOrderType; quantity: string; - targetPrice: string; - purchasePrice: string; + limitPrice: string; currentMarketPrice: string; - percentValue: string; + stopLossPercent: string; + takeProfitPercent: string; isCrypto: boolean; - notes: string; }; -type SimpleRuleEntry = ManualEntryPayload & { - stock_instance_id?: string; - label?: string | null; - notes?: string | null; +type SimpleTradePayload = { + symbol: string; + side: SimpleSide; + qty: number; + type: SimpleOrderType; + price?: number; + sl?: number; + tp?: number; }; -const SIMPLE_LABEL_PREFIX = 'SIMPLE '; - -const DEFAULT_DRAFT: SimpleRuleDraft = { +const DEFAULT_DRAFT: SimpleTradeDraft = { symbol: '', side: 'buy', - triggerMode: 'target_price', + orderType: 'market', quantity: '', - targetPrice: '', - purchasePrice: '', + limitPrice: '', currentMarketPrice: '', - percentValue: '', + stopLossPercent: '', + takeProfitPercent: '', isCrypto: false, - notes: '', }; function parsePositiveNumber(value: string): number | null { @@ -59,79 +54,97 @@ function roundPrice(value: number): number { return Number(value.toFixed(4)); } -export function computeSimpleTriggerPrice(draft: Pick): number | null { - if (draft.triggerMode === 'target_price') { - return parsePositiveNumber(draft.targetPrice); +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)); } - const percentValue = parsePositiveNumber(draft.percentValue); - if (percentValue === null) return null; - - if (draft.triggerMode === 'profit_percent') { - const purchasePrice = parsePositiveNumber(draft.purchasePrice); - if (purchasePrice === null) return null; - return roundPrice(purchasePrice * (1 + percentValue / 100)); + if (takeProfitPercent && takeProfitPercent > 0) { + tp = side === 'buy' + ? roundPrice(referencePrice * (1 + takeProfitPercent / 100)) + : roundPrice(referencePrice * (1 - takeProfitPercent / 100)); } - const currentMarketPrice = parsePositiveNumber(draft.currentMarketPrice); - if (currentMarketPrice === null) return null; - return roundPrice(currentMarketPrice * (1 - percentValue / 100)); + return { sl, tp }; } -function buildSimpleRuleNote(symbol: string, side: SimpleSide, triggerMode: SimpleTriggerMode, triggerPrice: number, percentValue: number | null): string { - if (triggerMode === 'target_price') { - return `${side.toUpperCase()} ${symbol} when price hits ${triggerPrice.toFixed(4)}`; - } - if (triggerMode === 'profit_percent') { - return `SELL ${symbol} at ${percentValue?.toFixed(2)}% profit (${triggerPrice.toFixed(4)}) from purchase price`; - } - return `BUY ${symbol} after ${percentValue?.toFixed(2)}% drop from current market (${triggerPrice.toFixed(4)})`; -} - -export function buildSimpleEntryPayload(userId: string, draft: SimpleRuleDraft): ManualEntryPayload { +export function buildSimpleTradePayload(draft: SimpleTradeDraft): SimpleTradePayload { const symbol = draft.symbol.trim().toUpperCase(); if (!symbol) { throw new Error('Symbol is required'); } - const triggerPrice = computeSimpleTriggerPrice(draft); - if (triggerPrice === null) { - throw new Error('A valid trigger price could not be calculated'); + const qty = parsePositiveNumber(draft.quantity); + if (qty === null) { + throw new Error('Quantity is required'); } - const quantity = parsePositiveNumber(draft.quantity); - const purchasePrice = parsePositiveNumber(draft.purchasePrice); - const percentValue = parsePositiveNumber(draft.percentValue); - const notePrefix = buildSimpleRuleNote(symbol, draft.side, draft.triggerMode, triggerPrice, percentValue); - const notes = draft.notes.trim() ? `${notePrefix}. ${draft.notes.trim()}` : notePrefix; + const price = draft.orderType === 'limit' ? parsePositiveNumber(draft.limitPrice) : null; + if (draft.orderType === 'limit' && price === null) { + throw new Error('Limit price is required'); + } - return { + const referencePrice = price ?? parsePositiveNumber(draft.currentMarketPrice); + if (referencePrice === null) { + 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, - active: true, - status: 'active', - user_id: userId, - quantity, - is_crypto: draft.isCrypto, - is_real_trade: false, - label: `${SIMPLE_LABEL_PREFIX}${draft.side.toUpperCase()}`, - notes, - entry_price: purchasePrice, - buy_price: draft.side === 'buy' ? triggerPrice : purchasePrice, - sell_price: draft.side === 'sell' ? triggerPrice : null, - gain_threshold_for_sell: draft.triggerMode === 'profit_percent' ? percentValue : null, - drop_threshold_for_buy: draft.triggerMode === 'drop_percent' ? percentValue : null, + side: draft.side, + qty, + type: draft.orderType, + sl, + tp, }; + + if (price !== null) { + payload.price = price; + } + + return payload; } -function isSimpleRule(entry: SimpleRuleEntry): boolean { - return String(entry.label || '').toUpperCase().startsWith(SIMPLE_LABEL_PREFIX); +function buildExecutionPreview(payload: SimpleTradePayload | null) { + if (!payload) 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)}`; + + const extras = [ + `Qty ${payload.qty}`, + payload.sl ? `SL ${payload.sl.toFixed(4)}` : null, + payload.tp ? `TP ${payload.tp.toFixed(4)}` : null, + ].filter(Boolean); + + return `${orderText} · ${extras.join(' · ')}`; } export function SimpleView() { - const { user } = useAuth(); const { botState } = useAppContext(); - const [draft, setDraft] = useState(DEFAULT_DRAFT); - const [savedRules, setSavedRules] = useState([]); + const [draft, setDraft] = useState(DEFAULT_DRAFT); const [submitting, setSubmitting] = useState(false); const [loadingPrice, setLoadingPrice] = useState(false); const [message, setMessage] = useState(null); @@ -139,69 +152,40 @@ export function SimpleView() { const normalizedSymbol = draft.symbol.trim().toUpperCase(); const livePrice = normalizedSymbol ? Number(botState.symbols?.[normalizedSymbol]?.price || 0) : 0; - const computedTriggerPrice = computeSimpleTriggerPrice(draft); const resolvedMarketPrice = parsePositiveNumber(draft.currentMarketPrice); const marketPriceSource = livePrice > 0 ? 'live' : (resolvedMarketPrice !== null ? 'recent_close' : null); - const triggerOptions = draft.side === 'buy' - ? [ - { value: 'target_price' as const, label: 'Buy when price hits target' }, - { value: 'drop_percent' as const, label: 'Buy after % drop from current market' }, - ] - : [ - { value: 'target_price' as const, label: 'Sell when price hits target' }, - { value: 'profit_percent' as const, label: 'Sell at % profit from purchase' }, - ]; - - async function loadSavedRules() { - try { - const rows = await fetchManualEntries(); - setSavedRules(rows.filter(isSimpleRule)); - } catch (err: any) { - setError(err?.message ?? 'Failed to load simple rules'); - } - } - useEffect(() => { - if (user) { - loadSavedRules(); - } - }, [user]); - - useEffect(() => { - if (draft.side === 'buy' && draft.triggerMode === 'profit_percent') { - setDraft(prev => ({ ...prev, triggerMode: 'target_price' })); - } - if (draft.side === 'sell' && draft.triggerMode === 'drop_percent') { - setDraft(prev => ({ ...prev, triggerMode: 'target_price' })); - } - }, [draft.side, draft.triggerMode]); - - useEffect(() => { - if (draft.triggerMode !== 'drop_percent' || !livePrice) { - return; - } + if (!livePrice) return; setDraft(prev => ( prev.currentMarketPrice === livePrice.toFixed(4) ? prev : { ...prev, currentMarketPrice: livePrice.toFixed(4) } )); - }, [draft.triggerMode, livePrice]); + }, [livePrice]); useEffect(() => { - if (draft.triggerMode !== 'drop_percent' || !normalizedSymbol || livePrice > 0 || draft.currentMarketPrice.trim() || loadingPrice) { + if (!normalizedSymbol || livePrice > 0 || draft.currentMarketPrice.trim() || loadingPrice) { return; } void handleLoadMarketPrice(); - }, [draft.triggerMode, normalizedSymbol, livePrice]); + }, [normalizedSymbol, livePrice]); - const rulePreview = useMemo(() => { - if (!normalizedSymbol || computedTriggerPrice === null) return null; - return buildSimpleRuleNote(normalizedSymbol, draft.side, draft.triggerMode, computedTriggerPrice, parsePositiveNumber(draft.percentValue)); - }, [normalizedSymbol, computedTriggerPrice, draft.side, draft.triggerMode, draft.percentValue]); + const payloadPreview = useMemo(() => { + try { + return buildSimpleTradePayload(draft); + } catch { + return null; + } + }, [draft]); - function updateDraft(key: K, value: SimpleRuleDraft[K]) { + const executionPreview = useMemo( + () => buildExecutionPreview(payloadPreview), + [payloadPreview], + ); + + function updateDraft(key: K, value: SimpleTradeDraft[K]) { setDraft(prev => ({ ...prev, [key]: value })); } @@ -223,7 +207,7 @@ export function SimpleView() { updateDraft('currentMarketPrice', latestClose.toFixed(4)); setMessage(`Loaded market price for ${normalizedSymbol}`); } catch (err: any) { - setError(err?.message ?? 'Failed to load current market price'); + setError(err?.message ?? 'Failed to load market price'); } finally { setLoadingPrice(false); } @@ -231,39 +215,43 @@ export function SimpleView() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); - if (!user?.id) { - setError('Not authenticated'); - return; - } setSubmitting(true); setError(null); setMessage(null); + try { - const payload = buildSimpleEntryPayload(user.id, draft); - await createManualEntry(payload); - setDraft(DEFAULT_DRAFT); - setMessage('Simple rule saved to your watchlist'); - await loadSavedRules(); + const payload = buildSimpleTradePayload(draft); + const accessToken = await getPlatformAccessToken(); + 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})`); + } + + setMessage(`Order submitted${result.orderId ? ` · ${result.orderId}` : ''}`); + setDraft(prev => ({ + ...DEFAULT_DRAFT, + symbol: prev.symbol, + isCrypto: prev.isCrypto, + currentMarketPrice: prev.currentMarketPrice, + })); } catch (err: any) { - setError(err?.message ?? 'Failed to save simple rule'); + setError(err?.message ?? 'Failed to submit trade'); } finally { setSubmitting(false); } } - async function handleDelete(ruleId: string) { - if (!confirm('Delete this simple rule?')) return; - try { - await deleteManualEntry(ruleId); - await loadSavedRules(); - setMessage('Simple rule deleted'); - setError(null); - } catch (err: any) { - setError(err?.message ?? 'Failed to delete simple rule'); - } - } - return (

Simple

@@ -280,30 +268,15 @@ export function SimpleView() {
- Simple Triggers + Direct Execution
- Save a buy or sell rule without the full strategy builder + Submit a simple buy or sell without the full strategy builder
- This creates a tracked rule in your watchlist using the existing manual-entry system. It does not place a broker-native conditional order by itself. + This page sends a direct trade request to your configured trading profile. Market price is sourced automatically from live data or the most recent close.
- - Open Watchlist -
@@ -331,15 +304,14 @@ export function SimpleView() { @@ -348,7 +320,7 @@ export function SimpleView() { updateDraft('quantity', e.target.value)} - placeholder="Optional" + placeholder="e.g. 5" inputMode="decimal" style={{ border: '1px solid #D1D5DB', borderRadius: 10, padding: '10px 12px', fontSize: 14 }} /> @@ -356,12 +328,54 @@ export function SimpleView() {
- {draft.triggerMode === 'target_price' && ( -
- -
-
- Saved Simple Rules -
- - {savedRules.length === 0 ? ( -
- No saved simple rules yet. -
- ) : ( -
- {savedRules.map(rule => ( -
-
-
- {rule.symbol} - - {rule.label} - -
-
- {rule.notes || 'Simple trigger rule'} -
-
- {rule.buy_price != null && Buy trigger: {Number(rule.buy_price).toFixed(4)}} - {rule.sell_price != null && Sell trigger: {Number(rule.sell_price).toFixed(4)}} - {rule.quantity != null && Qty: {rule.quantity}} -
-
- - {rule.stock_instance_id && ( - - )} -
- ))} -
- )} -
); }