feat(simple): improve setup status and market fallback
This commit is contained in:
parent
92747b76a7
commit
fc4d4c85d1
@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { Pencil, RefreshCw, Trash2 } from 'lucide-react';
|
import { Pencil, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
import { useAppContext } from '../context/AppContext';
|
import { useAppContext } from '../context/AppContext';
|
||||||
import { fetchChartBars } from '../lib/marketApi';
|
import { fetchChartBars, fetchResearchProfile } from '../lib/marketApi';
|
||||||
import {
|
import {
|
||||||
createManualEntry,
|
createManualEntry,
|
||||||
deleteManualEntry,
|
deleteManualEntry,
|
||||||
@ -42,6 +42,14 @@ type SimpleHolding = {
|
|||||||
tradeId?: string;
|
tradeId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SimpleRuntimeSnapshot = {
|
||||||
|
stage: 'armed' | 'entry_submitted' | 'filled' | 'exit_submitted' | 'closed' | 'unknown';
|
||||||
|
label: string;
|
||||||
|
tone: 'neutral' | 'info' | 'success';
|
||||||
|
tradeId?: string;
|
||||||
|
orderId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile';
|
const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile';
|
||||||
const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase();
|
const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase();
|
||||||
const SIMPLE_SYMBOL_DATALIST_ID = 'simple-supported-symbols';
|
const SIMPLE_SYMBOL_DATALIST_ID = 'simple-supported-symbols';
|
||||||
@ -247,27 +255,44 @@ function buildPreviewText(draft: SimpleSetupDraft, holding: SimpleHolding | null
|
|||||||
return `Exit ${symbol} when price reaches ${profitTargetPrice.toFixed(4)} (${profitText}).`;
|
return `Exit ${symbol} when price reaches ${profitTargetPrice.toFixed(4)} (${profitText}).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveRuntimeOrderStatus(
|
function deriveRuntimeSnapshot(
|
||||||
entry: ManualEntryPayload,
|
entry: ManualEntryPayload,
|
||||||
orders: Array<Record<string, any>>,
|
orders: Array<Record<string, any>>,
|
||||||
holdings: SimpleHolding[],
|
holdings: SimpleHolding[],
|
||||||
): string | null {
|
): SimpleRuntimeSnapshot | null {
|
||||||
const linkedTradeId = String(entry.linked_trade_id || '').trim();
|
const linkedTradeId = String(entry.linked_trade_id || '').trim();
|
||||||
if (linkedTradeId) {
|
if (linkedTradeId) {
|
||||||
const matchingOrder = orders.find((order) => String(order?.trade_id || order?.tradeId || '').trim() === linkedTradeId);
|
const matchingOrder = orders.find((order) => String(order?.trade_id || order?.tradeId || '').trim() === linkedTradeId);
|
||||||
if (matchingOrder) {
|
if (matchingOrder) {
|
||||||
const status = String(matchingOrder.status || '').trim();
|
const status = String(matchingOrder.status || '').trim().toLowerCase();
|
||||||
return status ? `Order: ${status}` : null;
|
const action = String(matchingOrder.action || '').trim().toUpperCase();
|
||||||
|
const orderId = String(matchingOrder.orderId || matchingOrder.order_id || matchingOrder.id || '').trim() || undefined;
|
||||||
|
if (action === 'EXIT') {
|
||||||
|
if (status === 'filled') {
|
||||||
|
return { stage: 'closed', label: 'Exit filled', tone: 'success', tradeId: linkedTradeId, orderId };
|
||||||
|
}
|
||||||
|
return { stage: 'exit_submitted', label: `Exit ${status || 'submitted'}`, tone: 'info', tradeId: linkedTradeId, orderId };
|
||||||
|
}
|
||||||
|
if (status === 'filled') {
|
||||||
|
return { stage: 'filled', label: 'Entry filled', tone: 'success', tradeId: linkedTradeId, orderId };
|
||||||
|
}
|
||||||
|
return { stage: 'entry_submitted', label: `Entry ${status || 'submitted'}`, tone: 'info', tradeId: linkedTradeId, orderId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const symbol = String(entry.symbol || '').trim().toUpperCase();
|
const symbol = String(entry.symbol || '').trim().toUpperCase();
|
||||||
const side = normalizeSetupSide(entry.simple_side);
|
const side = normalizeSetupSide(entry.simple_side);
|
||||||
if (side === 'buy' && holdings.some((holding) => holding.symbol === symbol)) {
|
const matchingHolding = holdings.find((holding) => holding.symbol === symbol && (!linkedTradeId || holding.tradeId === linkedTradeId || !holding.tradeId));
|
||||||
return 'Portfolio: open holding';
|
if (side === 'buy' && matchingHolding) {
|
||||||
|
return {
|
||||||
|
stage: 'filled',
|
||||||
|
label: 'Portfolio holding open',
|
||||||
|
tone: 'success',
|
||||||
|
tradeId: matchingHolding.tradeId || linkedTradeId || undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (String(entry.status || '').trim().toLowerCase() === 'sellcompleted') {
|
if (String(entry.status || '').trim().toLowerCase() === 'sellcompleted') {
|
||||||
return 'Portfolio: closed';
|
return { stage: 'closed', label: 'Position closed', tone: 'success', tradeId: linkedTradeId || undefined };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -308,6 +333,25 @@ function formatSetupStatus(status?: string | null): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusToneClasses(tone: SimpleRuntimeSnapshot['tone']): string {
|
||||||
|
switch (tone) {
|
||||||
|
case 'success':
|
||||||
|
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
|
||||||
|
case 'info':
|
||||||
|
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300';
|
||||||
|
default:
|
||||||
|
return 'border-[var(--border)] text-[var(--muted-foreground)]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSetupUpdatedAt(entry: ManualEntryPayload): string | null {
|
||||||
|
const raw = String(entry.sell_time || entry.buy_time || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = new Date(raw);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
|
return parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSimpleEntries(entries: ManualEntryPayload[]): ManualEntryPayload[] {
|
function normalizeSimpleEntries(entries: ManualEntryPayload[]): ManualEntryPayload[] {
|
||||||
return entries
|
return entries
|
||||||
.filter((entry) => String(entry.workflow_type || '').trim().toLowerCase() === 'simple')
|
.filter((entry) => String(entry.workflow_type || '').trim().toLowerCase() === 'simple')
|
||||||
@ -339,6 +383,23 @@ function isLikelySymbolCandidate(symbol: string): boolean {
|
|||||||
return /^[A-Z0-9./-]{1,20}$/.test(symbol);
|
return /^[A-Z0-9./-]{1,20}$/.test(symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractReferencePriceFromResearchProfile(profile: any): number | null {
|
||||||
|
const candidates = [
|
||||||
|
profile?.price,
|
||||||
|
profile?.lastPrice,
|
||||||
|
profile?.currentPrice,
|
||||||
|
profile?.previousClose,
|
||||||
|
profile?.prevClose,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const value = Number(candidate);
|
||||||
|
if (Number.isFinite(value) && value > 0) return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function describeSavedSetup(entry: ManualEntryPayload): string {
|
function describeSavedSetup(entry: ManualEntryPayload): string {
|
||||||
const side = normalizeSetupSide(entry.simple_side);
|
const side = normalizeSetupSide(entry.simple_side);
|
||||||
const symbol = String(entry.symbol || '').trim().toUpperCase();
|
const symbol = String(entry.symbol || '').trim().toUpperCase();
|
||||||
@ -535,7 +596,13 @@ export function SimpleView() {
|
|||||||
updateDraft('currentMarketPrice', lastClose.toFixed(4));
|
updateDraft('currentMarketPrice', lastClose.toFixed(4));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No recent market data is available for this symbol right now.');
|
const researchProfile = await fetchResearchProfile(requestSymbol).catch(() => null);
|
||||||
|
const fallbackReferencePrice = extractReferencePriceFromResearchProfile(researchProfile);
|
||||||
|
if (fallbackReferencePrice && marketPriceRequestSymbolRef.current === requestSymbol) {
|
||||||
|
updateDraft('currentMarketPrice', fallbackReferencePrice.toFixed(4));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('No live price or latest close is available for this symbol right now.');
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (marketPriceRequestSymbolRef.current === requestSymbol) {
|
if (marketPriceRequestSymbolRef.current === requestSymbol) {
|
||||||
@ -899,7 +966,8 @@ export function SimpleView() {
|
|||||||
const entryId = String(entry.stock_instance_id || '');
|
const entryId = String(entry.stock_instance_id || '');
|
||||||
const side = normalizeSetupSide(entry.simple_side);
|
const side = normalizeSetupSide(entry.simple_side);
|
||||||
const isEditing = editingSetupId === entryId;
|
const isEditing = editingSetupId === entryId;
|
||||||
const runtimeStatus = deriveRuntimeOrderStatus(entry, runtimeOrders, simpleHoldings);
|
const runtimeSnapshot = deriveRuntimeSnapshot(entry, runtimeOrders, simpleHoldings);
|
||||||
|
const updatedAt = formatSetupUpdatedAt(entry);
|
||||||
return (
|
return (
|
||||||
<div key={entryId} className="rounded-[1.5rem] border border-[var(--border)] bg-[var(--card-elevated)] p-5">
|
<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">
|
<div className="mb-3 flex items-start justify-between gap-4">
|
||||||
@ -946,6 +1014,16 @@ export function SimpleView() {
|
|||||||
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
||||||
{formatSetupStatus(entry.status)}
|
{formatSetupStatus(entry.status)}
|
||||||
</span>
|
</span>
|
||||||
|
{runtimeSnapshot ? (
|
||||||
|
<span className={`rounded-full border px-3 py-1 ${statusToneClasses(runtimeSnapshot.tone)}`}>
|
||||||
|
{runtimeSnapshot.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
||||||
|
{String(entry.sizing_mode || '').trim().toLowerCase() === 'amount'
|
||||||
|
? `Budget $${Number(entry.amount_usd || 0).toFixed(2)}`
|
||||||
|
: `Qty ${entry.quantity || entry.filled_quantity || 0}`}
|
||||||
|
</span>
|
||||||
{entry.reference_price ? (
|
{entry.reference_price ? (
|
||||||
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
||||||
Ref {Number(entry.reference_price).toFixed(4)}
|
Ref {Number(entry.reference_price).toFixed(4)}
|
||||||
@ -956,14 +1034,19 @@ export function SimpleView() {
|
|||||||
Entry {Number(entry.entry_price).toFixed(4)}
|
Entry {Number(entry.entry_price).toFixed(4)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{entry.linked_trade_id ? (
|
{(runtimeSnapshot?.tradeId || entry.linked_trade_id) ? (
|
||||||
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
||||||
Trade linked
|
Trade {String(runtimeSnapshot?.tradeId || entry.linked_trade_id).slice(0, 18)}…
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{runtimeStatus ? (
|
{runtimeSnapshot?.orderId ? (
|
||||||
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
||||||
{runtimeStatus}
|
Order {runtimeSnapshot.orderId.slice(0, 12)}…
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{updatedAt ? (
|
||||||
|
<span className="rounded-full border border-[var(--border)] px-3 py-1 normal-case tracking-normal">
|
||||||
|
Updated {updatedAt}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user