feat(simple): save dip-buy and profit-exit setups
This commit is contained in:
parent
0bd46ab43b
commit
90e733b46c
@ -21,6 +21,80 @@ import { reconciliationWatchdogAutoResumeService } from './services/reconciliati
|
|||||||
import { listActiveTradeProfiles } from './services/profileRepository.js';
|
import { listActiveTradeProfiles } from './services/profileRepository.js';
|
||||||
import { listActiveTradingUsers } from './services/userRepository.js';
|
import { listActiveTradingUsers } from './services/userRepository.js';
|
||||||
import * as runtimeOrderRepository from './services/runtimeOrderRepository.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() {
|
async function main() {
|
||||||
logger.info(`Starting ${config.PRODUCT_ID} trading backend...`);
|
logger.info(`Starting ${config.PRODUCT_ID} trading backend...`);
|
||||||
@ -105,6 +179,153 @@ async function main() {
|
|||||||
}
|
}
|
||||||
return Array.from(symbols);
|
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 = (
|
const buildOrderSyncHandler = (
|
||||||
executor: TradeExecutor,
|
executor: TradeExecutor,
|
||||||
@ -512,6 +733,14 @@ async function main() {
|
|||||||
|
|
||||||
logger.info(`Monitoring ${collectMonitoredSymbols().join(', ')} via DATA=${config.DATA_PROVIDER} and EXECUTION=${config.EXECUTION_PROVIDER}`);
|
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) ---
|
// --- State variables for periodic alerts (Global Signal State) ---
|
||||||
const assetState = new Map<string, {
|
const assetState = new Map<string, {
|
||||||
price: number;
|
price: number;
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
deleteTradeProfileForUser,
|
deleteTradeProfileForUser,
|
||||||
ensureDefaultTradeProfileForUser,
|
ensureDefaultTradeProfileForUser,
|
||||||
|
ensureSimpleAutoProfileForUser,
|
||||||
getCurrentUserProfile,
|
getCurrentUserProfile,
|
||||||
getTradeProfileForUser,
|
getTradeProfileForUser,
|
||||||
listAllTradeProfiles,
|
listAllTradeProfiles,
|
||||||
@ -2031,7 +2032,13 @@ export class ApiServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 });
|
res.status(201).json({ entry });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(400).json({ error: `Failed to save manual entry: ${error.message}` });
|
res.status(400).json({ error: `Failed to save manual entry: ${error.message}` });
|
||||||
@ -2046,10 +2053,16 @@ export class ApiServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = await saveManualEntryForUser(authUserId, {
|
const payload = {
|
||||||
...(req.body || {}),
|
...(req.body || {}),
|
||||||
stock_instance_id: String(req.params.id || '').trim()
|
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 });
|
res.json({ entry });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(400).json({ error: `Failed to update manual entry: ${error.message}` });
|
res.status(400).json({ error: `Failed to update manual entry: ${error.message}` });
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export interface ManualEntryRecord {
|
|||||||
symbol: string;
|
symbol: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
profile_id?: string | null;
|
||||||
buy_price?: number | null;
|
buy_price?: number | null;
|
||||||
sell_price?: number | null;
|
sell_price?: number | null;
|
||||||
buy_time?: string | null;
|
buy_time?: string | null;
|
||||||
@ -29,8 +30,14 @@ export interface ManualEntryRecord {
|
|||||||
is_real_trade: boolean;
|
is_real_trade: boolean;
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
entry_price?: number | null;
|
entry_price?: number | null;
|
||||||
|
reference_price?: number | null;
|
||||||
gain_threshold_for_sell?: number | null;
|
gain_threshold_for_sell?: number | null;
|
||||||
drop_threshold_for_buy?: 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 & {
|
type ManualEntryDocument = ManualEntryRecord & {
|
||||||
@ -61,6 +68,7 @@ function normalizeEntry(userId: string, input: Partial<ManualEntryRecord>, exist
|
|||||||
symbol: String(input.symbol || existing?.symbol || '').trim(),
|
symbol: String(input.symbol || existing?.symbol || '').trim(),
|
||||||
active: Boolean(input.active ?? existing?.active ?? true),
|
active: Boolean(input.active ?? existing?.active ?? true),
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
profile_id: normalizeNullableString(input.profile_id ?? existing?.profile_id),
|
||||||
buy_price: normalizeNullableNumber(input.buy_price ?? existing?.buy_price),
|
buy_price: normalizeNullableNumber(input.buy_price ?? existing?.buy_price),
|
||||||
sell_price: normalizeNullableNumber(input.sell_price ?? existing?.sell_price),
|
sell_price: normalizeNullableNumber(input.sell_price ?? existing?.sell_price),
|
||||||
buy_time: normalizeNullableString(input.buy_time ?? existing?.buy_time),
|
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),
|
is_real_trade: Boolean(input.is_real_trade ?? existing?.is_real_trade ?? false),
|
||||||
label: normalizeNullableString(input.label ?? existing?.label),
|
label: normalizeNullableString(input.label ?? existing?.label),
|
||||||
entry_price: normalizeNullableNumber(input.entry_price ?? existing?.entry_price),
|
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),
|
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),
|
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,9 @@ export interface TradeProfileRecord {
|
|||||||
updated_at?: string;
|
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 {
|
export interface TradeProfileCapitalSummary {
|
||||||
allocatedCapital: number;
|
allocatedCapital: number;
|
||||||
isActive: boolean;
|
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(
|
function normalizeTradingUserProfile(
|
||||||
row: Partial<TradingUserProfile> | null | undefined,
|
row: Partial<TradingUserProfile> | null | undefined,
|
||||||
fallbackUserId?: string
|
fallbackUserId?: string
|
||||||
@ -491,6 +525,33 @@ export async function ensureDefaultTradeProfileForUser(userId: string): Promise<
|
|||||||
return [created];
|
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(
|
export async function saveTradeProfileForUser(
|
||||||
input: Partial<TradeProfileRecord>,
|
input: Partial<TradeProfileRecord>,
|
||||||
userId: string
|
userId: string
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export interface ManualEntryPayload {
|
|||||||
symbol: string;
|
symbol: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
|
profile_id?: string | null;
|
||||||
buy_price?: number | null;
|
buy_price?: number | null;
|
||||||
sell_price?: number | null;
|
sell_price?: number | null;
|
||||||
buy_time?: string | null;
|
buy_time?: string | null;
|
||||||
@ -19,8 +20,14 @@ export interface ManualEntryPayload {
|
|||||||
is_real_trade: boolean;
|
is_real_trade: boolean;
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
entry_price?: number | null;
|
entry_price?: number | null;
|
||||||
|
reference_price?: number | null;
|
||||||
gain_threshold_for_sell?: number | null;
|
gain_threshold_for_sell?: number | null;
|
||||||
drop_threshold_for_buy?: 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> {
|
async function getAccessToken(): Promise<string> {
|
||||||
|
|||||||
@ -1,96 +1,113 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import { buildSimpleSetupPayload } from './SimpleView';
|
||||||
buildSimpleAutoProfilePayload,
|
|
||||||
buildSimpleTradePayload,
|
|
||||||
computeProtectionPrices,
|
|
||||||
SIMPLE_AUTO_PROFILE_NAME,
|
|
||||||
} from './SimpleView';
|
|
||||||
|
|
||||||
describe('SimpleView helpers', () => {
|
describe('SimpleView helpers', () => {
|
||||||
it('computes buy-side protection prices from reference price', () => {
|
it('builds a buy dip setup payload with dollar drop and percent profit target', () => {
|
||||||
expect(
|
const payload = buildSimpleSetupPayload({
|
||||||
computeProtectionPrices({
|
draft: {
|
||||||
|
symbol: 'aapl',
|
||||||
side: 'buy',
|
side: 'buy',
|
||||||
referencePrice: 100,
|
quantity: '5',
|
||||||
stopLossPercent: 5,
|
currentMarketPrice: '210.25',
|
||||||
takeProfitPercent: 8,
|
dropMode: 'dollar',
|
||||||
}),
|
dropValue: '12',
|
||||||
).toEqual({ sl: 95, tp: 108 });
|
profitMode: 'percent',
|
||||||
});
|
profitValue: '8',
|
||||||
|
notes: 'Long-term compounder',
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
Loading…
Reference in New Issue
Block a user