feat(simple): add long-term hold mode
This commit is contained in:
parent
0b526f3499
commit
a1a63cc945
@ -65,6 +65,10 @@ const isSimpleSellEntry = (entry: ManualEntryRecord): boolean => {
|
||||
return String(entry.simple_side || '').trim().toLowerCase() === 'sell';
|
||||
};
|
||||
|
||||
const isSimpleLongTermHold = (entry: ManualEntryRecord): boolean => {
|
||||
return String(entry.holding_mode || '').trim().toLowerCase() === 'long_term';
|
||||
};
|
||||
|
||||
const isSimpleSubmittedStatus = (status?: string | null): boolean => {
|
||||
return String(status || '').trim().toLowerCase() === 'simple_entry_submitted';
|
||||
};
|
||||
@ -291,6 +295,8 @@ async function main() {
|
||||
filled_quantity: activePosition.size,
|
||||
buy_time: entry.buy_time || new Date().toISOString(),
|
||||
status: 'simple_bought',
|
||||
holding_mode: entry.holding_mode || 'short_term',
|
||||
automation_state: isSimpleLongTermHold(entry) ? 'paused_long_term' : 'holding_managed',
|
||||
active: true,
|
||||
});
|
||||
emitSimpleSetupEvent(
|
||||
@ -363,6 +369,8 @@ async function main() {
|
||||
filled_quantity: result.adjustedQty ?? entry.filled_quantity,
|
||||
buy_time: new Date().toISOString(),
|
||||
status: 'simple_entry_submitted',
|
||||
holding_mode: entry.holding_mode || 'short_term',
|
||||
automation_state: 'entry_submitted',
|
||||
active: true,
|
||||
});
|
||||
emitSimpleSetupEvent(
|
||||
@ -390,6 +398,10 @@ async function main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSimpleLongTermHold(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkedTradeId = String(entry.linked_trade_id || '').trim();
|
||||
const exitDedupKey = linkedTradeId ? `${symbol}::${linkedTradeId}` : symbol;
|
||||
if (processedSimpleExitKeys.has(exitDedupKey)) {
|
||||
@ -417,6 +429,7 @@ async function main() {
|
||||
...entry,
|
||||
active: false,
|
||||
status: 'sellCompleted',
|
||||
automation_state: 'closed',
|
||||
sell_time: entry.sell_time || new Date().toISOString(),
|
||||
sell_price: currentPrice,
|
||||
});
|
||||
@ -455,6 +468,8 @@ async function main() {
|
||||
entry_price: activeEntryPrice,
|
||||
filled_quantity: entry.filled_quantity ?? activePosition.size,
|
||||
status: 'simple_exit_submitted',
|
||||
holding_mode: entry.holding_mode || 'short_term',
|
||||
automation_state: 'exit_submitted',
|
||||
active: true,
|
||||
});
|
||||
emitSimpleSetupEvent(
|
||||
@ -514,6 +529,8 @@ async function main() {
|
||||
filled_quantity: event.fillQty || simpleEntry.filled_quantity || simpleEntry.quantity,
|
||||
buy_time: simpleEntry.buy_time || new Date().toISOString(),
|
||||
status: 'simple_bought',
|
||||
holding_mode: simpleEntry.holding_mode || 'short_term',
|
||||
automation_state: isSimpleLongTermHold(simpleEntry) ? 'paused_long_term' : 'holding_managed',
|
||||
active: true,
|
||||
});
|
||||
emitSimpleSetupEvent(
|
||||
@ -534,6 +551,7 @@ async function main() {
|
||||
await saveManualEntryForUser(simpleEntry.user_id, {
|
||||
...simpleEntry,
|
||||
status: 'simple_armed_buy',
|
||||
automation_state: 'armed',
|
||||
active: true,
|
||||
buy_time: null,
|
||||
entry_price: null,
|
||||
@ -557,6 +575,7 @@ async function main() {
|
||||
await saveManualEntryForUser(simpleExitEntry.user_id, {
|
||||
...simpleExitEntry,
|
||||
status: 'simple_bought',
|
||||
automation_state: isSimpleLongTermHold(simpleExitEntry) ? 'paused_long_term' : 'holding_managed',
|
||||
active: true,
|
||||
});
|
||||
emitSimpleSetupEvent(
|
||||
@ -603,6 +622,7 @@ async function main() {
|
||||
await saveManualEntryForUser(simpleExitEntry.user_id, {
|
||||
...simpleExitEntry,
|
||||
status: 'simple_bought',
|
||||
automation_state: isSimpleLongTermHold(simpleExitEntry) ? 'paused_long_term' : 'holding_managed',
|
||||
active: true,
|
||||
filled_quantity: applied.remainingSize,
|
||||
});
|
||||
@ -621,6 +641,7 @@ async function main() {
|
||||
...simpleExitEntry,
|
||||
active: false,
|
||||
status: 'sellCompleted',
|
||||
automation_state: 'closed',
|
||||
sell_time: simpleExitEntry.sell_time || new Date().toISOString(),
|
||||
sell_price: event.fillPrice || active.entryPrice,
|
||||
});
|
||||
|
||||
@ -40,6 +40,8 @@ export interface ManualEntryRecord {
|
||||
drop_trigger_mode?: string | null;
|
||||
profit_target_mode?: string | null;
|
||||
linked_trade_id?: string | null;
|
||||
holding_mode?: string | null;
|
||||
automation_state?: string | null;
|
||||
}
|
||||
|
||||
type ManualEntryDocument = ManualEntryRecord & {
|
||||
@ -64,7 +66,42 @@ function normalizeNullableString(value: unknown): string | null | undefined {
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
function deriveSimpleAutomationState(status: string, holdingMode: string | null | undefined): string | null {
|
||||
const normalizedStatus = String(status || '').trim().toLowerCase();
|
||||
const normalizedMode = String(holdingMode || '').trim().toLowerCase();
|
||||
if (normalizedMode === 'long_term') {
|
||||
return normalizedStatus === 'sellcompleted' ? 'closed' : 'paused_long_term';
|
||||
}
|
||||
switch (normalizedStatus) {
|
||||
case 'simple_armed_buy':
|
||||
case 'simple_armed_sell':
|
||||
return 'armed';
|
||||
case 'simple_entry_submitted':
|
||||
return 'entry_submitted';
|
||||
case 'simple_bought':
|
||||
return 'holding_managed';
|
||||
case 'simple_exit_submitted':
|
||||
return 'exit_submitted';
|
||||
case 'sellcompleted':
|
||||
return 'closed';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEntry(userId: string, input: Partial<ManualEntryRecord>, existing?: ManualEntryRecord | null): ManualEntryRecord {
|
||||
const workflowType = normalizeNullableString(input.workflow_type ?? existing?.workflow_type);
|
||||
const holdingMode = normalizeNullableString(
|
||||
input.holding_mode
|
||||
?? existing?.holding_mode
|
||||
?? (String(workflowType || '').trim().toLowerCase() === 'simple' ? 'short_term' : null)
|
||||
);
|
||||
const status = String(input.status || existing?.status || 'active');
|
||||
const automationState = normalizeNullableString(
|
||||
input.automation_state
|
||||
?? existing?.automation_state
|
||||
?? deriveSimpleAutomationState(status, holdingMode)
|
||||
);
|
||||
return {
|
||||
stock_instance_id: String(input.stock_instance_id || existing?.stock_instance_id || randomUUID()),
|
||||
symbol: String(input.symbol || existing?.symbol || '').trim(),
|
||||
@ -80,7 +117,7 @@ function normalizeEntry(userId: string, input: Partial<ManualEntryRecord>, exist
|
||||
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'),
|
||||
status,
|
||||
is_crypto: Boolean(input.is_crypto ?? existing?.is_crypto ?? false),
|
||||
is_real_trade: Boolean(input.is_real_trade ?? existing?.is_real_trade ?? false),
|
||||
label: normalizeNullableString(input.label ?? existing?.label),
|
||||
@ -88,11 +125,13 @@ function normalizeEntry(userId: string, input: Partial<ManualEntryRecord>, exist
|
||||
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),
|
||||
workflow_type: workflowType,
|
||||
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),
|
||||
holding_mode: holdingMode,
|
||||
automation_state: automationState,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -30,6 +30,8 @@ export interface ManualEntryPayload {
|
||||
drop_trigger_mode?: string | null;
|
||||
profit_target_mode?: string | null;
|
||||
linked_trade_id?: string | null;
|
||||
holding_mode?: string | null;
|
||||
automation_state?: string | null;
|
||||
}
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
|
||||
@ -6,9 +6,9 @@ import type { BotState } from '../hooks/useWebSocket';
|
||||
import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket';
|
||||
import { createManualEntry, deleteManualEntry, fetchManualEntries, type ManualEntryPayload } from '../lib/manualEntriesApi';
|
||||
|
||||
interface Entry {
|
||||
stock_instance_id: string;
|
||||
symbol: string;
|
||||
interface Entry {
|
||||
stock_instance_id: string;
|
||||
symbol: string;
|
||||
active: boolean;
|
||||
user_id: string;
|
||||
buy_price?: string;
|
||||
@ -23,13 +23,19 @@ interface Entry {
|
||||
is_real_trade: boolean;
|
||||
label?: string;
|
||||
entry_price?: string;
|
||||
gain_threshold_for_sell?: string;
|
||||
drop_threshold_for_buy?: string;
|
||||
}
|
||||
|
||||
export const filterEntriesByTab = (entries: Entry[], activeTab: string) =>
|
||||
entries.filter((entry) => {
|
||||
switch (activeTab) {
|
||||
gain_threshold_for_sell?: string;
|
||||
drop_threshold_for_buy?: string;
|
||||
workflow_type?: string;
|
||||
holding_mode?: string;
|
||||
automation_state?: string;
|
||||
}
|
||||
|
||||
export const filterEntriesByTab = (entries: Entry[], activeTab: string) =>
|
||||
entries.filter((entry) => {
|
||||
if (String(entry.workflow_type || '').trim().toLowerCase() === 'simple') {
|
||||
return false;
|
||||
}
|
||||
switch (activeTab) {
|
||||
case 'paperActive':
|
||||
return !entry.is_real_trade && entry.active && entry.status !== 'sellCompleted';
|
||||
case 'paperCompleted':
|
||||
|
||||
@ -23,11 +23,13 @@ interface HybridPosition {
|
||||
pnl?: number | null;
|
||||
pnlPercent?: number | null;
|
||||
stopLoss?: number;
|
||||
takeProfit?: number;
|
||||
profileId?: string;
|
||||
profileName?: string;
|
||||
tradeId?: string;
|
||||
}
|
||||
takeProfit?: number;
|
||||
profileId?: string;
|
||||
profileName?: string;
|
||||
tradeId?: string;
|
||||
planMode?: 'short_term' | 'long_term';
|
||||
planState?: string | null;
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
@ -414,7 +416,8 @@ export const assignLifecycleTradeIds = (
|
||||
|
||||
export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
||||
const { user, profile } = useAuth();
|
||||
const [manualPositions, setManualPositions] = useState<HybridPosition[]>([]);
|
||||
const [manualPositions, setManualPositions] = useState<HybridPosition[]>([]);
|
||||
const [simplePlanMetaByTradeId, setSimplePlanMetaByTradeId] = useState<Record<string, { holdingMode: 'short_term' | 'long_term'; automationState?: string | null }>>({});
|
||||
const [dbOrders, setDbOrders] = useState<RawOrderRecord[]>([]);
|
||||
const [historyTradeKeys, setHistoryTradeKeys] = useState<string[]>([]);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
@ -498,10 +501,26 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
||||
pnl: null,
|
||||
pnlPercent: null,
|
||||
stopLoss: entry.drop_threshold_for_buy,
|
||||
takeProfit: entry.gain_threshold_for_sell
|
||||
takeProfit: entry.gain_threshold_for_sell
|
||||
})).filter((p: { size: number; entryPrice: number; }) => p.size > 0 && p.entryPrice > 0);
|
||||
setManualPositions(positions);
|
||||
}
|
||||
setManualPositions(positions);
|
||||
|
||||
const nextSimplePlanMetaByTradeId = Object.fromEntries(
|
||||
posData
|
||||
.filter((entry: any) => String(entry.workflow_type || '').trim().toLowerCase() === 'simple')
|
||||
.map((entry: any) => {
|
||||
const tradeId = String(entry.linked_trade_id || '').trim();
|
||||
if (!tradeId) return null;
|
||||
const holdingMode = String(entry.holding_mode || '').trim().toLowerCase() === 'long_term' ? 'long_term' : 'short_term';
|
||||
return [tradeId, {
|
||||
holdingMode,
|
||||
automationState: String(entry.automation_state || '').trim() || null,
|
||||
}] as const;
|
||||
})
|
||||
.filter(Boolean) as Array<readonly [string, { holdingMode: 'short_term' | 'long_term'; automationState?: string | null }]>
|
||||
);
|
||||
setSimplePlanMetaByTradeId(nextSimplePlanMetaByTradeId);
|
||||
}
|
||||
|
||||
setDbOrders(ordData || []);
|
||||
setHistoryTradeKeys(tradeKeys);
|
||||
@ -543,8 +562,14 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
||||
takeProfit: p.takeProfit,
|
||||
profileId: p.profileId,
|
||||
profileName: p.profileName,
|
||||
tradeId: p.tradeId,
|
||||
};
|
||||
tradeId: p.tradeId,
|
||||
planMode: p.tradeId && simplePlanMetaByTradeId[p.tradeId]
|
||||
? simplePlanMetaByTradeId[p.tradeId].holdingMode
|
||||
: undefined,
|
||||
planState: p.tradeId && simplePlanMetaByTradeId[p.tradeId]
|
||||
? simplePlanMetaByTradeId[p.tradeId].automationState || null
|
||||
: null,
|
||||
};
|
||||
|
||||
const tradeId = String(normalized.tradeId || '').trim();
|
||||
const dedupeKey = tradeId
|
||||
@ -567,7 +592,7 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
||||
});
|
||||
}
|
||||
return Array.from(deduped.values());
|
||||
}, [botState.positions]);
|
||||
}, [botState.positions, simplePlanMetaByTradeId]);
|
||||
|
||||
const managedSymbols = useMemo(() => {
|
||||
return new Set(Object.keys(botState.symbols || {}).map((symbol) => String(symbol).toUpperCase()));
|
||||
@ -1358,9 +1383,20 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
||||
<span className="text-[10px] text-zinc-700">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 font-mono font-bold text-white">{pos.symbol}</td>
|
||||
<td className="px-6 py-4 font-mono font-bold text-white">{pos.symbol}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`text-[10px] font-black ${pos.side === 'BUY' ? 'text-green-400' : 'text-red-400'}`}>{pos.side}</span>
|
||||
{pos.planMode ? (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-black uppercase tracking-wider border ${
|
||||
pos.planMode === 'long_term'
|
||||
? 'bg-amber-500/10 text-amber-300 border-amber-500/20'
|
||||
: 'bg-sky-500/10 text-sky-300 border-sky-500/20'
|
||||
}`}>
|
||||
{pos.planMode === 'long_term' ? 'Long-term hold' : 'Short-term managed'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs font-mono text-gray-300">
|
||||
{formatDisplayQty(pos.size)}
|
||||
|
||||
@ -70,7 +70,8 @@ describe('tab helper coverage', () => {
|
||||
const entries = [
|
||||
{ stock_instance_id: '1', is_real_trade: false, active: true, status: 'active' },
|
||||
{ stock_instance_id: '2', is_real_trade: false, active: false, status: 'sellCompleted' },
|
||||
{ stock_instance_id: '3', is_real_trade: true, active: true, status: 'active' }
|
||||
{ stock_instance_id: '3', is_real_trade: true, active: true, status: 'active' },
|
||||
{ stock_instance_id: '4', is_real_trade: false, active: true, status: 'simple_bought', workflow_type: 'simple' }
|
||||
] as any;
|
||||
|
||||
expect(filterEntriesByTab(entries, 'paperActive')).toHaveLength(1);
|
||||
|
||||
@ -42,6 +42,8 @@ describe('SimpleView helpers', () => {
|
||||
profit_target_mode: 'percent',
|
||||
linked_trade_id: null,
|
||||
profile_id: null,
|
||||
holding_mode: 'short_term',
|
||||
automation_state: 'armed',
|
||||
buy_price: null,
|
||||
sell_price: null,
|
||||
buy_time: null,
|
||||
@ -96,6 +98,8 @@ describe('SimpleView helpers', () => {
|
||||
profit_target_mode: 'dollar',
|
||||
linked_trade_id: 'TRD-123',
|
||||
profile_id: 'simple-profile',
|
||||
holding_mode: 'short_term',
|
||||
automation_state: 'armed',
|
||||
buy_price: null,
|
||||
sell_price: null,
|
||||
buy_time: null,
|
||||
@ -143,6 +147,8 @@ describe('SimpleView helpers', () => {
|
||||
profit_target_mode: 'dollar',
|
||||
linked_trade_id: null,
|
||||
profile_id: null,
|
||||
holding_mode: 'short_term',
|
||||
automation_state: 'armed',
|
||||
buy_price: null,
|
||||
sell_price: null,
|
||||
buy_time: null,
|
||||
@ -150,6 +156,40 @@ describe('SimpleView helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves long-term mode when editing an existing setup', () => {
|
||||
const payload = buildSimpleSetupPayload({
|
||||
draft: {
|
||||
symbol: 'aapl',
|
||||
side: 'buy',
|
||||
sizingMode: 'quantity',
|
||||
quantity: '5',
|
||||
amountUsd: '',
|
||||
currentMarketPrice: '210.25',
|
||||
dropMode: 'dollar',
|
||||
dropValue: '12',
|
||||
profitMode: 'percent',
|
||||
profitValue: '8',
|
||||
notes: 'Long-term compounder',
|
||||
},
|
||||
existingId: 'simple-1',
|
||||
existingEntry: {
|
||||
stock_instance_id: 'simple-1',
|
||||
symbol: 'AAPL',
|
||||
active: true,
|
||||
status: 'simple_bought',
|
||||
is_crypto: false,
|
||||
is_real_trade: false,
|
||||
workflow_type: 'simple',
|
||||
simple_side: 'buy',
|
||||
holding_mode: 'long_term',
|
||||
automation_state: 'paused_long_term',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(payload.holding_mode).toBe('long_term');
|
||||
expect(payload.automation_state).toBe('paused_long_term');
|
||||
});
|
||||
|
||||
it('rejects sell setups without an existing holding', () => {
|
||||
expect(() => buildSimpleSetupPayload({
|
||||
draft: {
|
||||
|
||||
@ -115,6 +115,35 @@ function normalizeMode(value: unknown, fallback: TriggerMode = 'percent'): Trigg
|
||||
return String(value || '').trim().toLowerCase() === 'dollar' ? 'dollar' : fallback;
|
||||
}
|
||||
|
||||
function normalizeHoldingMode(value: unknown): 'short_term' | 'long_term' {
|
||||
return String(value || '').trim().toLowerCase() === 'long_term' ? 'long_term' : 'short_term';
|
||||
}
|
||||
|
||||
function normalizeAutomationState(value: unknown, entry: ManualEntryPayload): string {
|
||||
const explicit = String(value || '').trim().toLowerCase();
|
||||
if (explicit) return explicit;
|
||||
const status = String(entry.status || '').trim().toLowerCase();
|
||||
const holdingMode = normalizeHoldingMode(entry.holding_mode);
|
||||
if (holdingMode === 'long_term') {
|
||||
return status === 'sellcompleted' ? 'closed' : 'paused_long_term';
|
||||
}
|
||||
switch (status) {
|
||||
case 'simple_armed_buy':
|
||||
case 'simple_armed_sell':
|
||||
return 'armed';
|
||||
case 'simple_entry_submitted':
|
||||
return 'entry_submitted';
|
||||
case 'simple_bought':
|
||||
return 'holding_managed';
|
||||
case 'simple_exit_submitted':
|
||||
return 'exit_submitted';
|
||||
case 'sellcompleted':
|
||||
return 'closed';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function computeBuyTriggerPrice(draft: SimpleSetupDraft): number | null {
|
||||
const currentMarketPrice = parsePositiveNumber(draft.currentMarketPrice);
|
||||
const dropValue = parseNonNegativeNumber(draft.dropValue);
|
||||
@ -145,6 +174,7 @@ export function buildSimpleSetupPayload(input: {
|
||||
draft: SimpleSetupDraft;
|
||||
existingId?: string;
|
||||
holding?: SimpleHolding | null;
|
||||
existingEntry?: ManualEntryPayload | null;
|
||||
}): ManualEntryPayload {
|
||||
const symbol = input.draft.symbol.trim().toUpperCase();
|
||||
if (!symbol) {
|
||||
@ -166,6 +196,9 @@ export function buildSimpleSetupPayload(input: {
|
||||
|
||||
const side = input.draft.side;
|
||||
const holding = input.holding || null;
|
||||
const existingEntry = input.existingEntry || null;
|
||||
const holdingMode = normalizeHoldingMode(existingEntry?.holding_mode);
|
||||
const automationState = normalizeAutomationState(existingEntry?.automation_state, existingEntry || { status: side === 'buy' ? 'simple_armed_buy' : 'simple_armed_sell' } as ManualEntryPayload);
|
||||
|
||||
if (side === 'sell' && !holding) {
|
||||
throw new Error('Sell setups require an existing Simple holding for this symbol.');
|
||||
@ -208,6 +241,8 @@ export function buildSimpleSetupPayload(input: {
|
||||
profit_target_mode: input.draft.profitMode,
|
||||
linked_trade_id: side === 'sell' ? holding!.tradeId || null : null,
|
||||
profile_id: side === 'sell' ? holding!.profileId || null : null,
|
||||
holding_mode: existingEntry ? holdingMode : 'short_term',
|
||||
automation_state: existingEntry ? automationState : 'armed',
|
||||
buy_price: null,
|
||||
sell_price: null,
|
||||
buy_time: null,
|
||||
@ -341,6 +376,30 @@ function formatSetupStatus(status?: string | null): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatHoldingMode(mode?: string | null): string {
|
||||
return normalizeHoldingMode(mode) === 'long_term' ? 'Long-term' : 'Short-term';
|
||||
}
|
||||
|
||||
function formatAutomationState(entry: ManualEntryPayload): string {
|
||||
const state = normalizeAutomationState(entry.automation_state, entry);
|
||||
switch (state) {
|
||||
case 'armed':
|
||||
return 'Automation armed';
|
||||
case 'entry_submitted':
|
||||
return 'Entry syncing';
|
||||
case 'holding_managed':
|
||||
return 'Exit managed';
|
||||
case 'paused_long_term':
|
||||
return 'Automation paused';
|
||||
case 'exit_submitted':
|
||||
return 'Exit syncing';
|
||||
case 'closed':
|
||||
return 'Closed';
|
||||
default:
|
||||
return 'State syncing';
|
||||
}
|
||||
}
|
||||
|
||||
function statusToneClasses(tone: SimpleRuntimeSnapshot['tone']): string {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
@ -395,11 +454,18 @@ function describeNextAction(
|
||||
const symbol = String(entry.symbol || '').trim().toUpperCase() || 'This symbol';
|
||||
|
||||
if (!runtimeSnapshot) {
|
||||
if (normalizeHoldingMode(entry.holding_mode) === 'long_term') {
|
||||
return `${symbol} is being kept as a long-term hold. Automated exit monitoring is paused until you resume it.`;
|
||||
}
|
||||
return side === 'buy'
|
||||
? `${symbol} is saved and waiting for the configured buy trigger.`
|
||||
: `${symbol} is saved and waiting for an eligible holding to manage.`;
|
||||
}
|
||||
|
||||
if (normalizeHoldingMode(entry.holding_mode) === 'long_term' && runtimeSnapshot.stage === 'filled') {
|
||||
return `${symbol} is held as a long-term position. No automated profit exit is currently armed.`;
|
||||
}
|
||||
|
||||
switch (runtimeSnapshot.stage) {
|
||||
case 'armed':
|
||||
return side === 'buy'
|
||||
@ -708,6 +774,15 @@ export function SimpleView() {
|
||||
setSavedSetups(normalizeSimpleEntries(entryRows));
|
||||
}
|
||||
|
||||
async function updateSavedSetup(entryId: string, updater: (entry: ManualEntryPayload) => ManualEntryPayload) {
|
||||
const existing = savedSetups.find((entry) => String(entry.stock_instance_id || '') === entryId);
|
||||
if (!existing) return;
|
||||
const updated = await updateManualEntry(entryId, updater(existing));
|
||||
setSavedSetups((prev) => normalizeSimpleEntries(prev.map((entry) => (
|
||||
String(entry.stock_instance_id || '') === entryId ? updated : entry
|
||||
))));
|
||||
}
|
||||
|
||||
function setMarketPriceValue(value: string, source: MarketPriceSource) {
|
||||
setMarketPriceSource(source);
|
||||
updateDraft('currentMarketPrice', value);
|
||||
@ -764,10 +839,14 @@ export function SimpleView() {
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const existingEntry = editingSetupId
|
||||
? (savedSetups.find((entry) => String(entry.stock_instance_id || '') === editingSetupId) || null)
|
||||
: null;
|
||||
const payload = buildSimpleSetupPayload({
|
||||
draft,
|
||||
existingId: editingSetupId || undefined,
|
||||
holding: draft.side === 'sell' ? matchingHolding : null,
|
||||
existingEntry,
|
||||
});
|
||||
|
||||
if (editingSetupId) {
|
||||
@ -818,6 +897,44 @@ export function SimpleView() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConvertToLongTerm(entry: ManualEntryPayload) {
|
||||
const entryId = String(entry.stock_instance_id || '');
|
||||
if (!entryId) return;
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
await updateSavedSetup(entryId, (current) => ({
|
||||
...current,
|
||||
holding_mode: 'long_term',
|
||||
automation_state: 'paused_long_term',
|
||||
status: 'simple_bought',
|
||||
active: true,
|
||||
}));
|
||||
setMessage(`${String(entry.symbol || '').trim().toUpperCase()} is now treated as a long-term hold. Automated exit monitoring is paused.`);
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to convert setup to long-term mode');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResumeExitManagement(entry: ManualEntryPayload) {
|
||||
const entryId = String(entry.stock_instance_id || '');
|
||||
if (!entryId) return;
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
await updateSavedSetup(entryId, (current) => ({
|
||||
...current,
|
||||
holding_mode: 'short_term',
|
||||
automation_state: 'holding_managed',
|
||||
status: 'simple_bought',
|
||||
active: true,
|
||||
}));
|
||||
setMessage(`${String(entry.symbol || '').trim().toUpperCase()} is back under short-term exit management.`);
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to resume short-term exit management');
|
||||
}
|
||||
}
|
||||
|
||||
const saveButtonLabel = editingSetupId ? 'Update setup' : 'Save setup';
|
||||
const saveButtonDisabled = submitting || loadingPrice || (draft.side === 'sell' && !matchingHolding);
|
||||
|
||||
@ -1139,6 +1256,9 @@ export function SimpleView() {
|
||||
const nextActionText = describeNextAction(entry, runtimeSnapshot);
|
||||
const updatedAt = formatSetupUpdatedAt(entry);
|
||||
const eventHistory = deriveSimpleEventHistory(entry, runtimeEvents);
|
||||
const holdingMode = normalizeHoldingMode(entry.holding_mode);
|
||||
const canConvertToLongTerm = side === 'buy' && holdingMode === 'short_term' && runtimeSnapshot?.stage === 'filled';
|
||||
const canResumeExitManagement = side === 'buy' && holdingMode === 'long_term' && runtimeSnapshot?.stage === 'filled';
|
||||
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">
|
||||
@ -1154,6 +1274,28 @@ export function SimpleView() {
|
||||
<p className="mt-2 text-sm text-[var(--muted-foreground)]">{describeSavedSetup(entry)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canConvertToLongTerm ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleConvertToLongTerm(entry)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="uppercase tracking-[0.18em]"
|
||||
>
|
||||
Convert to long-term
|
||||
</Button>
|
||||
) : null}
|
||||
{canResumeExitManagement ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleResumeExitManagement(entry)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="uppercase tracking-[0.18em]"
|
||||
>
|
||||
Resume exit management
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleEdit(entry)}
|
||||
@ -1185,6 +1327,12 @@ export function SimpleView() {
|
||||
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
||||
{formatSetupStatus(entry.status)}
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
||||
{formatHoldingMode(entry.holding_mode)}
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
||||
{formatAutomationState(entry)}
|
||||
</span>
|
||||
{runtimeSnapshot ? (
|
||||
<span className={`rounded-full border px-3 py-1 ${statusToneClasses(runtimeSnapshot.tone)}`}>
|
||||
{runtimeSnapshot.label}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user