feat(simple): add amount sizing and runtime status

This commit is contained in:
root 2026-05-06 07:04:48 +00:00
parent dad47fc13d
commit 3d505db8d8
11 changed files with 387 additions and 75 deletions

View File

@ -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<string, (value: unknown) => 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),

View File

@ -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,
});
}

View File

@ -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:

View File

@ -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();

View File

@ -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

View File

@ -410,11 +410,34 @@ export class TradeExecutor {
return false;
}
private async hasActiveTradeId(tradeId?: string): Promise<boolean> {
const normalized = String(tradeId || '').trim();
if (!normalized) return false;
private async hasActiveTradeId(tradeId?: string): Promise<boolean> {
const normalized = String(tradeId || '').trim();
if (!normalized) return false;
return await runtimeOrderRepository.hasActiveOrderForTradeId(normalized, this.profileId);
}
}
private async hasOpenLifecycleEvidence(profileId: string, symbol: string): Promise<boolean> {
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<boolean> {
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();

View File

@ -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<RuntimeLockDocument | null> {

View File

@ -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<ManualEntryRecord>, 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'),

View File

@ -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;

View File

@ -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: '',

View File

@ -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<Record<string, any>>,
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"
/>
<datalist id={SIMPLE_SYMBOL_DATALIST_ID}>
{supportedSymbols.map((symbol) => (
<option key={symbol} value={symbol} />
))}
</datalist>
<span className="block text-[11px] text-[var(--muted-foreground)]">
Start typing to pick a supported symbol. Suggestions come from live market symbols plus common supported assets.
</span>
{normalizedSymbol && !isSuggestedSymbol ? (
<span className="block text-[11px] text-amber-700 dark:text-amber-300">
This symbol is not in the current supported suggestions. Double-check it before saving.
</span>
) : null}
{filteredSymbolSuggestions.length > 0 ? (
<div className="flex flex-wrap gap-2 pt-1">
{filteredSymbolSuggestions.map((symbol) => (
<button
key={symbol}
type="button"
onClick={() => setDraft((prev) => ({
...prev,
symbol,
currentMarketPrice: '',
}))}
className={`rounded-full border px-3 py-1 text-[11px] font-semibold transition ${
symbol === normalizedSymbol
? 'border-[var(--primary)] bg-[var(--accent-soft)] text-[var(--primary)]'
: 'border-[var(--border)] bg-[var(--card-elevated)] text-[var(--muted-foreground)] hover:border-[var(--primary)] hover:text-[var(--foreground)]'
}`}
>
{symbol}
</button>
))}
</div>
) : null}
</label>
<label className="space-y-2">
@ -561,18 +689,53 @@ export function SimpleView() {
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-500">
{draft.side === 'buy' ? 'Planned quantity' : 'Holding size'}
</span>
<Input
value={draft.side === 'sell' && matchingHolding ? String(matchingHolding.size) : draft.quantity}
onChange={(e) => updateDraft('quantity', e.target.value)}
readOnly={draft.side === 'sell' && !!matchingHolding}
className="read-only:bg-[var(--muted)]"
placeholder="10"
/>
</label>
{draft.side === 'buy' ? (
<>
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Sizing method</span>
<Select
value={draft.sizingMode}
onChange={(e) => updateDraft('sizingMode', e.target.value as 'quantity' | 'amount')}
>
<option value="quantity">Quantity / fractional shares</option>
<option value="amount">USD amount</option>
</Select>
</label>
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-500">
{draft.sizingMode === 'amount' ? 'Spend amount (USD)' : 'Planned quantity'}
</span>
<Input
value={draft.sizingMode === 'amount' ? draft.amountUsd : draft.quantity}
onChange={(e) => {
if (draft.sizingMode === 'amount') {
updateDraft('amountUsd', e.target.value);
} else {
updateDraft('quantity', e.target.value);
}
}}
placeholder={draft.sizingMode === 'amount' ? '250' : '10'}
/>
<span className="block text-[11px] text-[var(--muted-foreground)]">
Quantity supports fractional shares/coins. Amount spends an approximate USD budget at trigger time.
</span>
</label>
</>
) : (
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-500">
Holding size
</span>
<Input
value={draft.side === 'sell' && matchingHolding ? String(matchingHolding.size) : draft.quantity}
onChange={(e) => updateDraft('quantity', e.target.value)}
readOnly={draft.side === 'sell' && !!matchingHolding}
className="read-only:bg-[var(--muted)]"
placeholder="10"
/>
</label>
)}
<label className="space-y-2">
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Notes</span>
@ -639,7 +802,7 @@ export function SimpleView() {
: 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
}`}>
{matchingHolding
? `Simple holding ready: ${matchingHolding.symbol} · ${matchingHolding.size} shares at ${matchingHolding.entryPrice.toFixed(4)}`
? `Simple holding ready: ${matchingHolding.symbol} · ${matchingHolding.size} units at ${matchingHolding.entryPrice.toFixed(4)}. Executed Simple buys also appear in Portfolio as live positions.`
: 'No existing Simple holding found for this symbol. Sell setups only arm against a current Simple holding.'}
</div>
)}
@ -677,7 +840,7 @@ export function SimpleView() {
<Card>
<CardHeader className="block">
<CardTitle className="uppercase">Saved setups</CardTitle>
<CardDescription>Review and update armed simple workflows in the same layout style used across the app.</CardDescription>
<CardDescription>Review armed setups, current runtime order state, and whether an executed setup is now showing in Portfolio as an open holding.</CardDescription>
</CardHeader>
<CardContent>
@ -692,6 +855,7 @@ export function SimpleView() {
const entryId = String(entry.stock_instance_id || '');
const side = normalizeSetupSide(entry.simple_side);
const isEditing = editingSetupId === entryId;
const runtimeStatus = deriveRuntimeOrderStatus(entry, runtimeOrders, simpleHoldings);
return (
<div key={entryId} className="rounded-[1.5rem] border border-[var(--border)] bg-[var(--card-elevated)] p-5">
<div className="mb-3 flex items-start justify-between gap-4">
@ -753,6 +917,11 @@ export function SimpleView() {
Trade linked
</span>
) : null}
{runtimeStatus ? (
<span className="rounded-full border border-[var(--border)] px-3 py-1">
{runtimeStatus}
</span>
) : null}
</div>
</div>
);