diff --git a/web/src/components/layout/AppShell.dom.test.tsx b/web/src/components/layout/AppShell.dom.test.tsx index 0256aa6..d987718 100644 --- a/web/src/components/layout/AppShell.dom.test.tsx +++ b/web/src/components/layout/AppShell.dom.test.tsx @@ -28,6 +28,10 @@ vi.mock('../../views/ResearchView', () => ({ ResearchView: () =>
Research view
, })); +vi.mock('../../views/SimpleView', () => ({ + SimpleView: () =>
Simple view
, +})); + vi.mock('../../views/MarketsView', () => ({ MarketsView: () =>
Markets view
, })); diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index ccfe5cc..29d725c 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -5,6 +5,7 @@ import { RightPanel } from './RightPanel'; import { HomeView } from '../../views/HomeView'; import { PortfolioView } from '../../views/PortfolioView'; import { ResearchView } from '../../views/ResearchView'; +import { SimpleView } from '../../views/SimpleView'; import { MarketsView } from '../../views/MarketsView'; import { ScreenerView } from '../../views/ScreenerView'; import { WatchlistView } from '../../views/WatchlistView'; @@ -78,6 +79,7 @@ export function AppShell() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index c913a09..50781b8 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ import { NavLink } from 'react-router-dom'; import { - Home, Briefcase, FlaskConical, TrendingUp, + Home, Briefcase, FlaskConical, Target, TrendingUp, SlidersHorizontal, Star, Bell, Settings, } from 'lucide-react'; import { useAppContext } from '../../context/AppContext'; @@ -9,6 +9,7 @@ const NAV = [ { to: '/', icon: Home, label: 'Home', end: true }, { to: '/portfolio', icon: Briefcase, label: 'Portfolio', end: false }, { to: '/research', icon: FlaskConical, label: 'Research', end: false }, + { to: '/simple', icon: Target, label: 'Simple', end: false }, { to: '/markets', icon: TrendingUp, label: 'Markets', end: false }, { to: '/screener', icon: SlidersHorizontal, label: 'Screener', end: false }, { to: '/watchlist', icon: Star, label: 'Watchlist', end: false }, diff --git a/web/src/views/SimpleView.test.ts b/web/src/views/SimpleView.test.ts new file mode 100644 index 0000000..81b6ba0 --- /dev/null +++ b/web/src/views/SimpleView.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { buildSimpleEntryPayload, computeSimpleTriggerPrice } from './SimpleView'; + +describe('SimpleView helpers', () => { + it('computes a buy trigger from current market drop percent', () => { + expect( + computeSimpleTriggerPrice({ + triggerMode: 'drop_percent', + targetPrice: '', + purchasePrice: '', + currentMarketPrice: '100', + percentValue: '7.5', + }), + ).toBe(92.5); + }); + + it('builds a sell profit rule payload from purchase price', () => { + const payload = buildSimpleEntryPayload('user-1', { + symbol: 'aapl', + side: 'sell', + triggerMode: 'profit_percent', + quantity: '5', + targetPrice: '', + purchasePrice: '200', + currentMarketPrice: '', + percentValue: '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'); + }); + + it('builds a buy target price payload', () => { + const payload = buildSimpleEntryPayload('user-1', { + symbol: 'btc/usd', + side: 'buy', + triggerMode: 'target_price', + quantity: '', + targetPrice: '64000', + purchasePrice: '', + currentMarketPrice: '', + percentValue: '', + 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); + }); +}); diff --git a/web/src/views/SimpleView.tsx b/web/src/views/SimpleView.tsx new file mode 100644 index 0000000..7956ffa --- /dev/null +++ b/web/src/views/SimpleView.tsx @@ -0,0 +1,598 @@ +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 { useAppContext } from '../context/AppContext'; +import { fetchChartBars } from '../lib/marketApi'; +import { + createManualEntry, + deleteManualEntry, + fetchManualEntries, + type ManualEntryPayload, +} from '../lib/manualEntriesApi'; + +type SimpleSide = 'buy' | 'sell'; +type SimpleTriggerMode = 'target_price' | 'profit_percent' | 'drop_percent'; + +type SimpleRuleDraft = { + symbol: string; + side: SimpleSide; + triggerMode: SimpleTriggerMode; + quantity: string; + targetPrice: string; + purchasePrice: string; + currentMarketPrice: string; + percentValue: string; + isCrypto: boolean; + notes: string; +}; + +type SimpleRuleEntry = ManualEntryPayload & { + stock_instance_id?: string; + label?: string | null; + notes?: string | null; +}; + +const SIMPLE_LABEL_PREFIX = 'SIMPLE '; + +const DEFAULT_DRAFT: SimpleRuleDraft = { + symbol: '', + side: 'buy', + triggerMode: 'target_price', + quantity: '', + targetPrice: '', + purchasePrice: '', + currentMarketPrice: '', + percentValue: '', + isCrypto: false, + notes: '', +}; + +function parsePositiveNumber(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const parsed = Number(trimmed); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function 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); + } + + 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)); + } + + const currentMarketPrice = parsePositiveNumber(draft.currentMarketPrice); + if (currentMarketPrice === null) return null; + return roundPrice(currentMarketPrice * (1 - percentValue / 100)); +} + +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 { + 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 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; + + return { + 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, + }; +} + +function isSimpleRule(entry: SimpleRuleEntry): boolean { + return String(entry.label || '').toUpperCase().startsWith(SIMPLE_LABEL_PREFIX); +} + +export function SimpleView() { + const { user } = useAuth(); + const { botState } = useAppContext(); + const [draft, setDraft] = useState(DEFAULT_DRAFT); + const [savedRules, setSavedRules] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [loadingPrice, setLoadingPrice] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const normalizedSymbol = draft.symbol.trim().toUpperCase(); + const livePrice = normalizedSymbol ? Number(botState.symbols?.[normalizedSymbol]?.price || 0) : 0; + const computedTriggerPrice = computeSimpleTriggerPrice(draft); + + 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 || draft.currentMarketPrice.trim()) { + return; + } + setDraft(prev => ({ ...prev, currentMarketPrice: livePrice.toFixed(4) })); + }, [draft.triggerMode, draft.currentMarketPrice, 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]); + + function updateDraft(key: K, value: SimpleRuleDraft[K]) { + setDraft(prev => ({ ...prev, [key]: value })); + } + + async function handleLoadMarketPrice() { + if (!normalizedSymbol) { + setError('Enter a symbol first'); + 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}`); + } + updateDraft('currentMarketPrice', latestClose.toFixed(4)); + setMessage(`Loaded current market price for ${normalizedSymbol}`); + } catch (err: any) { + setError(err?.message ?? 'Failed to load current market price'); + } finally { + setLoadingPrice(false); + } + } + + 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(); + } catch (err: any) { + setError(err?.message ?? 'Failed to save simple rule'); + } 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

+ +
+
+
+
+ Simple Triggers +
+
+ Save a buy or sell rule 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. +
+
+ + Open Watchlist + +
+ +
+
+ + + + + + + +
+ +
+ {draft.triggerMode === 'target_price' && ( + + )} + + {draft.triggerMode === 'profit_percent' && ( + <> + + + + )} + + {draft.triggerMode === 'drop_percent' && ( + <> + + + + )} + + {draft.side === 'sell' && draft.triggerMode === 'target_price' && ( + + )} + + +
+ +
+ +
+ +
+
+ + + Rule Preview + +
+
+ {rulePreview ?? 'Complete the fields to preview the simple rule.'} +
+ {computedTriggerPrice !== null && ( +
+ Trigger Price: {computedTriggerPrice.toFixed(4)} +
+ )} +
+ + {(error || message) && ( +
+ {error || message} +
+ )} + + +
+
+ +
+
+ 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 && ( + + )} +
+ ))} +
+ )} +
+
+ ); +}