feat(simple): save dip-buy and profit-exit setups

This commit is contained in:
root 2026-05-06 02:14:32 +00:00
parent 0bd46ab43b
commit 90e733b46c
7 changed files with 1030 additions and 536 deletions

View File

@ -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<boolean> => {
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<string, {
price: number;

View File

@ -22,6 +22,7 @@ import {
import {
deleteTradeProfileForUser,
ensureDefaultTradeProfileForUser,
ensureSimpleAutoProfileForUser,
getCurrentUserProfile,
getTradeProfileForUser,
listAllTradeProfiles,
@ -2031,7 +2032,13 @@ export class ApiServer {
}
try {
const entry = await saveManualEntryForUser(authUserId, req.body || {});
const payload = { ...(req.body || {}) };
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.status(201).json({ entry });
} catch (error: any) {
res.status(400).json({ error: `Failed to save manual entry: ${error.message}` });
@ -2046,10 +2053,16 @@ export class ApiServer {
}
try {
const entry = await saveManualEntryForUser(authUserId, {
const payload = {
...(req.body || {}),
stock_instance_id: String(req.params.id || '').trim()
});
} as Record<string, unknown>;
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}` });

View File

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

View File

@ -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<TradingUserProfile> | 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<TradeProfileRecord> {
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<TradeProfileRecord>,
userId: string

View File

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

View File

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

File diff suppressed because it is too large Load Diff