|
@@ -0,0 +1,718 @@
|
|
|
|
|
+'use client';
|
|
|
|
|
+
|
|
|
|
|
+import { useState, useCallback, useEffect } from 'react';
|
|
|
|
|
+import type { Signal } from '@/lib/types';
|
|
|
|
|
+
|
|
|
|
|
+type OrderStep = 'review' | 'pricing' | 'confirm' | 'result';
|
|
|
|
|
+type OrderMode = 'LIMIT' | 'MARKET';
|
|
|
|
|
+
|
|
|
|
|
+interface BookLevel {
|
|
|
|
|
+ price: number;
|
|
|
|
|
+ size: number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface PriceData {
|
|
|
|
|
+ signalId: string;
|
|
|
|
|
+ yesPrice: number;
|
|
|
|
|
+ noPrice: number;
|
|
|
|
|
+ // YES side
|
|
|
|
|
+ yesBestBid: number;
|
|
|
|
|
+ yesBestAsk: number;
|
|
|
|
|
+ yesSpread: number;
|
|
|
|
|
+ // NO side (direct from NO token orderbook)
|
|
|
|
|
+ noBestBid: number;
|
|
|
|
|
+ noBestAsk: number;
|
|
|
|
|
+ noSpread: number;
|
|
|
|
|
+ noAsks: BookLevel[];
|
|
|
|
|
+ yesAsks: BookLevel[];
|
|
|
|
|
+ // Legacy
|
|
|
|
|
+ bestBid: number;
|
|
|
|
|
+ bestAsk: number;
|
|
|
|
|
+ spread: number;
|
|
|
|
|
+ midpoint: number;
|
|
|
|
|
+ error?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface OrderResult {
|
|
|
|
|
+ signalId: string;
|
|
|
|
|
+ orderId: string;
|
|
|
|
|
+ status: 'DRY_RUN' | 'SUBMITTED' | 'FAILED';
|
|
|
|
|
+ side: string;
|
|
|
|
|
+ price: number;
|
|
|
|
|
+ size: number;
|
|
|
|
|
+ costBasis: number;
|
|
|
|
|
+ error?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const sideLabels: Record<string, { text: string; color: string; bg: string }> = {
|
|
|
|
|
+ BUY_YES: { text: 'BUY YES', color: 'text-green-600 dark:text-green-400', bg: 'bg-green-100 dark:bg-green-500/10' },
|
|
|
|
|
+ BUY_NO: { text: 'BUY NO', color: 'text-red-600 dark:text-red-400', bg: 'bg-red-100 dark:bg-red-500/10' },
|
|
|
|
|
+ BUY_ALL_YES: { text: 'BUY ALL YES', color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-100 dark:bg-blue-500/10' },
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function formatCity(city: string): string {
|
|
|
|
|
+ return city.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function OrderPanel({
|
|
|
|
|
+ signals,
|
|
|
|
|
+ onClose,
|
|
|
|
|
+ onComplete,
|
|
|
|
|
+}: {
|
|
|
|
|
+ signals: Signal[];
|
|
|
|
|
+ onClose: () => void;
|
|
|
|
|
+ onComplete: () => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const [step, setStep] = useState<OrderStep>('review');
|
|
|
|
|
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set(signals.map((s) => s.id)));
|
|
|
|
|
+ const [prices, setPrices] = useState<Map<string, PriceData>>(new Map());
|
|
|
|
|
+ const [orderMode, setOrderMode] = useState<OrderMode>('LIMIT');
|
|
|
|
|
+ const [customSizes, setCustomSizes] = useState<Map<string, number>>(
|
|
|
|
|
+ () => new Map(signals.map((s) => [s.id, s.recommendedSize])),
|
|
|
|
|
+ );
|
|
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
|
|
+ const [results, setResults] = useState<OrderResult[]>([]);
|
|
|
|
|
+ const [error, setError] = useState<string | null>(null);
|
|
|
|
|
+ const [isDryRun, setIsDryRun] = useState<boolean | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+ // Fetch actual DRY_RUN status from server
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ fetch('/api/bot-control')
|
|
|
|
|
+ .then((r) => r.json())
|
|
|
|
|
+ .then((data) => setIsDryRun(data.dryRun ?? false))
|
|
|
|
|
+ .catch(() => setIsDryRun(false));
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ const selectedSignals = signals.filter((s) => selectedIds.has(s.id));
|
|
|
|
|
+
|
|
|
|
|
+ const getSize = (id: string, fallback: number) => customSizes.get(id) ?? fallback;
|
|
|
|
|
+
|
|
|
|
|
+ const updateSize = (id: string, value: string) => {
|
|
|
|
|
+ const num = parseFloat(value);
|
|
|
|
|
+ setCustomSizes((prev) => {
|
|
|
|
|
+ const next = new Map(prev);
|
|
|
|
|
+ if (!isNaN(num) && num >= 0) {
|
|
|
|
|
+ next.set(id, num);
|
|
|
|
|
+ }
|
|
|
|
|
+ return next;
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const toggleSignal = (id: string) => {
|
|
|
|
|
+ setSelectedIds((prev) => {
|
|
|
|
|
+ const next = new Set(prev);
|
|
|
|
|
+ if (next.has(id)) next.delete(id);
|
|
|
|
|
+ else next.add(id);
|
|
|
|
|
+ return next;
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const toggleAll = () => {
|
|
|
|
|
+ if (selectedIds.size === signals.length) {
|
|
|
|
|
+ setSelectedIds(new Set());
|
|
|
|
|
+ } else {
|
|
|
|
|
+ setSelectedIds(new Set(signals.map((s) => s.id)));
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Step 1 → Step 2: Fetch latest prices
|
|
|
|
|
+ const fetchPrices = useCallback(async () => {
|
|
|
|
|
+ setLoading(true);
|
|
|
|
|
+ setError(null);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/api/prices', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
|
+ signals: selectedSignals.map((s) => ({
|
|
|
|
|
+ signalId: s.id,
|
|
|
|
|
+ city: s.city,
|
|
|
|
|
+ targetDate: s.targetDate,
|
|
|
|
|
+ bucket: s.bucket,
|
|
|
|
|
+ type: s.type,
|
|
|
|
|
+ side: s.side,
|
|
|
|
|
+ })),
|
|
|
|
|
+ }),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (!res.ok) throw new Error('Failed to fetch prices');
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ const priceMap = new Map<string, PriceData>();
|
|
|
|
|
+ for (const p of data.prices) {
|
|
|
|
|
+ priceMap.set(p.signalId, p);
|
|
|
|
|
+ }
|
|
|
|
|
+ setPrices(priceMap);
|
|
|
|
|
+ setStep('pricing');
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ setError(err instanceof Error ? err.message : 'Price fetch failed');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setLoading(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [selectedSignals]);
|
|
|
|
|
+
|
|
|
|
|
+ // Step 3: Place orders
|
|
|
|
|
+ const placeOrders = useCallback(async () => {
|
|
|
|
|
+ setLoading(true);
|
|
|
|
|
+ setError(null);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const orders = selectedSignals.map((signal) => {
|
|
|
|
|
+ const priceData = prices.get(signal.id);
|
|
|
|
|
+ let price: number;
|
|
|
|
|
+ if (orderMode === 'MARKET') {
|
|
|
|
|
+ // Market order: use actual best ask from the respective orderbook
|
|
|
|
|
+ if (signal.side === 'BUY_NO') {
|
|
|
|
|
+ // Use real NO best ask from NO token orderbook
|
|
|
|
|
+ price = priceData?.noBestAsk ?? (1 - signal.marketYesPrice);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // Use real YES best ask from YES token orderbook
|
|
|
|
|
+ price = priceData?.yesBestAsk ?? signal.marketYesPrice;
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // Limit order: use suggested price from signal
|
|
|
|
|
+ price = signal.suggestedPrice;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ signalId: signal.id,
|
|
|
|
|
+ side: signal.side === 'BUY_ALL_YES' ? 'BUY_YES' : signal.side,
|
|
|
|
|
+ price: Math.round(price * 100) / 100,
|
|
|
|
|
+ size: getSize(signal.id, signal.recommendedSize),
|
|
|
|
|
+ orderType: orderMode,
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const res = await fetch('/api/orders', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ orders }),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (!res.ok) throw new Error('Failed to place orders');
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ setResults(data.results);
|
|
|
|
|
+ setStep('result');
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ setError(err instanceof Error ? err.message : 'Order placement failed');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setLoading(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [selectedSignals, prices, orderMode]);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
|
|
|
+ <div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl border border-gray-200 dark:border-slate-700 w-full max-w-3xl max-h-[85vh] flex flex-col mx-4">
|
|
|
|
|
+ {/* Header */}
|
|
|
|
|
+ <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h2 className="text-lg font-semibold text-heading">Manual Order</h2>
|
|
|
|
|
+ <p className="text-xs text-sub mt-0.5">
|
|
|
|
|
+ {step === 'review' && 'Step 1: Review selected signals'}
|
|
|
|
|
+ {step === 'pricing' && 'Step 2: Review live prices & select order type'}
|
|
|
|
|
+ {step === 'confirm' && 'Step 3: Confirm and place orders'}
|
|
|
|
|
+ {step === 'result' && 'Order results'}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={onClose}
|
|
|
|
|
+ className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-5 h-5 text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Step indicator */}
|
|
|
|
|
+ <div className="px-6 py-3 border-b border-gray-100 dark:border-slate-700/50">
|
|
|
|
|
+ {(() => {
|
|
|
|
|
+ const steps: OrderStep[] = ['review', 'pricing', 'confirm', 'result'];
|
|
|
|
|
+ const currentIdx = steps.indexOf(step);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex items-center text-xs">
|
|
|
|
|
+ {steps.map((s, i) => {
|
|
|
|
|
+ const isCurrent = step === s;
|
|
|
|
|
+ const isPast = currentIdx > i;
|
|
|
|
|
+ const circleClass = isCurrent
|
|
|
|
|
+ ? 'bg-blue-600 text-white'
|
|
|
|
|
+ : isPast
|
|
|
|
|
+ ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
|
|
|
|
|
+ : 'bg-gray-200 text-gray-400 dark:bg-slate-700 dark:text-slate-500';
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={s} className="flex items-center">
|
|
|
|
|
+ {i > 0 && (
|
|
|
|
|
+ <div className={`w-12 h-px mx-1 ${currentIdx >= i ? 'bg-blue-400' : 'bg-gray-300 dark:bg-slate-600'}`} />
|
|
|
|
|
+ )}
|
|
|
|
|
+ <div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-medium shrink-0 ${circleClass}`}>
|
|
|
|
|
+ {i + 1}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })()}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Content */}
|
|
|
|
|
+ <div className="flex-1 overflow-y-auto px-6 py-4">
|
|
|
|
|
+ {error && (
|
|
|
|
|
+ <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 text-sm text-red-700 dark:text-red-400">
|
|
|
|
|
+ {error}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Step 1: Review & Select */}
|
|
|
|
|
+ {step === 'review' && (
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={toggleAll}
|
|
|
|
|
+ className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
|
|
|
|
+ >
|
|
|
|
|
+ {selectedIds.size === signals.length ? 'Deselect All' : 'Select All'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <span className="text-xs text-sub">{selectedIds.size} of {signals.length} selected</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ {signals.map((signal) => {
|
|
|
|
|
+ const side = sideLabels[signal.side];
|
|
|
|
|
+ const checked = selectedIds.has(signal.id);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <label
|
|
|
|
|
+ key={signal.id}
|
|
|
|
|
+ className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
|
|
|
|
+ checked
|
|
|
|
|
+ ? 'border-blue-300 bg-blue-50 dark:border-blue-500/30 dark:bg-blue-500/5'
|
|
|
|
|
+ : 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ checked={checked}
|
|
|
|
|
+ onChange={() => toggleSignal(signal.id)}
|
|
|
|
|
+ className="w-4 h-4 rounded border-gray-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
|
|
|
|
|
+ />
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <span className={`text-xs font-semibold ${side.color}`}>{side.text}</span>
|
|
|
|
|
+ <span className="text-xs text-dim">|</span>
|
|
|
|
|
+ <span className="text-sm font-medium text-heading">{formatCity(signal.city)}</span>
|
|
|
|
|
+ <span className="text-xs text-dim">{signal.targetDate.slice(5)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-3 mt-0.5">
|
|
|
|
|
+ <span className="text-xs text-sub font-mono">{signal.bucket}</span>
|
|
|
|
|
+ <span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
|
|
|
|
+ +{(signal.edge * 100).toFixed(1)}% edge
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-1.5" onClick={(e) => e.preventDefault()}>
|
|
|
|
|
+ <span className="text-xs text-dim">Shares:</span>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ min="1"
|
|
|
|
|
+ step="1"
|
|
|
|
|
+ value={getSize(signal.id, signal.recommendedSize)}
|
|
|
|
|
+ onChange={(e) => updateSize(signal.id, e.target.value)}
|
|
|
|
|
+ className="w-20 text-right text-sm font-mono px-2 py-1 rounded border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-heading focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Step 2: Live Prices */}
|
|
|
|
|
+ {step === 'pricing' && (
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ <div className="overflow-x-auto">
|
|
|
|
|
+ <table className="w-full text-sm">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr className="border-b border-gray-200 dark:border-slate-700">
|
|
|
|
|
+ <th className="table-header">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ checked={selectedIds.size === signals.length}
|
|
|
|
|
+ onChange={toggleAll}
|
|
|
|
|
+ className="w-3.5 h-3.5 rounded border-gray-300 dark:border-slate-600 text-blue-600"
|
|
|
|
|
+ />
|
|
|
|
|
+ </th>
|
|
|
|
|
+ <th className="table-header">Signal</th>
|
|
|
|
|
+ <th className="table-header text-right">Best Ask</th>
|
|
|
|
|
+ <th className="table-header text-right">Depth</th>
|
|
|
|
|
+ <th className="table-header text-right">Spread</th>
|
|
|
|
|
+ <th className="table-header text-right">Edge Now</th>
|
|
|
|
|
+ <th className="table-header text-right">Shares</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
|
|
|
|
|
+ {selectedSignals.map((signal) => {
|
|
|
|
|
+ const priceData = prices.get(signal.id);
|
|
|
|
|
+ const checked = selectedIds.has(signal.id);
|
|
|
|
|
+ const side = sideLabels[signal.side];
|
|
|
|
|
+ const isBuyNo = signal.side === 'BUY_NO';
|
|
|
|
|
+ const isParity = signal.type === 'PARITY_ARB';
|
|
|
|
|
+
|
|
|
|
|
+ // Live best ask from the actual orderbook for the side we're buying
|
|
|
|
|
+ const liveBestAsk = priceData
|
|
|
|
|
+ ? (isBuyNo ? priceData.noBestAsk : priceData.yesBestAsk)
|
|
|
|
|
+ : 0;
|
|
|
|
|
+ const liveSpread = priceData
|
|
|
|
|
+ ? (isBuyNo ? priceData.noSpread : priceData.yesSpread)
|
|
|
|
|
+ : 0;
|
|
|
|
|
+
|
|
|
|
|
+ // Top asks for depth display
|
|
|
|
|
+ const topAsks = priceData
|
|
|
|
|
+ ? (isBuyNo ? priceData.noAsks : priceData.yesAsks)
|
|
|
|
|
+ : [];
|
|
|
|
|
+
|
|
|
|
|
+ // Compute live edge using YES midpoint
|
|
|
|
|
+ const liveYesPrice = priceData?.midpoint ?? signal.marketYesPrice;
|
|
|
|
|
+ let edgeNow = 0;
|
|
|
|
|
+ if (signal.type === 'TAIL_NO') {
|
|
|
|
|
+ edgeNow = liveYesPrice - signal.modelProbability;
|
|
|
|
|
+ } else if (signal.type === 'LATENCY_ARB') {
|
|
|
|
|
+ edgeNow = Math.abs(signal.modelProbability - liveYesPrice);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ edgeNow = signal.edge;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Cost per share = best ask price, return per share = $1 if win
|
|
|
|
|
+ // ROI = (1 - bestAsk) / bestAsk
|
|
|
|
|
+ const roi = liveBestAsk > 0 && liveBestAsk < 1
|
|
|
|
|
+ ? (1 - liveBestAsk) / liveBestAsk
|
|
|
|
|
+ : 0;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <tr key={signal.id} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
|
|
|
|
|
+ <td className="table-cell">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ checked={checked}
|
|
|
|
|
+ onChange={() => toggleSignal(signal.id)}
|
|
|
|
|
+ className="w-3.5 h-3.5 rounded border-gray-300 dark:border-slate-600 text-blue-600"
|
|
|
|
|
+ />
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="table-cell">
|
|
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
|
|
+ <span className={`text-xs font-semibold ${side.color}`}>{side.text}</span>
|
|
|
|
|
+ <span className="text-xs text-heading">{formatCity(signal.city)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span className="text-xs text-sub font-mono">{signal.bucket}</span>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="table-cell text-right font-mono">
|
|
|
|
|
+ {priceData?.error ? (
|
|
|
|
|
+ <span className="text-red-500 text-xs">{priceData.error}</span>
|
|
|
|
|
+ ) : priceData ? (
|
|
|
|
|
+ <span className="text-heading font-medium">
|
|
|
|
|
+ {(liveBestAsk * 100).toFixed(1)}c
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <span className="text-dim">--</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="table-cell text-right font-mono text-xs">
|
|
|
|
|
+ {topAsks.length > 0 ? (
|
|
|
|
|
+ <div className="space-y-0.5">
|
|
|
|
|
+ {topAsks.slice(0, 3).map((ask, i) => (
|
|
|
|
|
+ <div key={i} className={i === 0 ? 'text-heading font-medium' : 'text-dim'}>
|
|
|
|
|
+ {(ask.price * 100).toFixed(1)}c × {ask.size.toFixed(0)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <span className="text-dim">--</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="table-cell text-right font-mono text-xs text-sub">
|
|
|
|
|
+ {priceData && !priceData.error
|
|
|
|
|
+ ? `${(liveSpread * 100).toFixed(1)}c`
|
|
|
|
|
+ : '--'}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="table-cell text-right font-mono">
|
|
|
|
|
+ {roi > 0 ? (
|
|
|
|
|
+ <span className={roi > 0.05 ? 'text-green-600 dark:text-green-400 font-medium' : 'text-yellow-600 dark:text-yellow-400'}>
|
|
|
|
|
+ +{(roi * 100).toFixed(0)}% ROI
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <span className="text-dim">--</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="table-cell text-right">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ min="1"
|
|
|
|
|
+ step="1"
|
|
|
|
|
+ value={getSize(signal.id, signal.recommendedSize)}
|
|
|
|
|
+ onChange={(e) => updateSize(signal.id, e.target.value)}
|
|
|
|
|
+ className="w-20 text-right text-sm font-mono px-2 py-1 rounded border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-heading focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
|
|
|
+ />
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Order type selection */}
|
|
|
|
|
+ <div className="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-4 border border-gray-200 dark:border-slate-700/50">
|
|
|
|
|
+ <h4 className="text-sm font-medium text-heading mb-3">Order Type</h4>
|
|
|
|
|
+ <div className="grid grid-cols-2 gap-3">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setOrderMode('LIMIT')}
|
|
|
|
|
+ className={`p-3 rounded-lg border text-left transition-colors ${
|
|
|
|
|
+ orderMode === 'LIMIT'
|
|
|
|
|
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10'
|
|
|
|
|
+ : 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <p className={`text-sm font-medium ${orderMode === 'LIMIT' ? 'text-blue-700 dark:text-blue-400' : 'text-heading'}`}>
|
|
|
|
|
+ Limit Order (Recommended)
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <p className="text-xs text-sub mt-0.5">
|
|
|
|
|
+ Place at suggested price. Zero fees as maker. May take time to fill.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setOrderMode('MARKET')}
|
|
|
|
|
+ className={`p-3 rounded-lg border text-left transition-colors ${
|
|
|
|
|
+ orderMode === 'MARKET'
|
|
|
|
|
+ ? 'border-orange-500 bg-orange-50 dark:bg-orange-500/10'
|
|
|
|
|
+ : 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <p className={`text-sm font-medium ${orderMode === 'MARKET' ? 'text-orange-700 dark:text-orange-400' : 'text-heading'}`}>
|
|
|
|
|
+ Market Order
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <p className="text-xs text-sub mt-0.5">
|
|
|
|
|
+ Fill at best available price. Immediate execution. Pays taker fees.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Step 3: Confirm */}
|
|
|
|
|
+ {step === 'confirm' && (
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ {isDryRun === null ? (
|
|
|
|
|
+ <div className="bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-700 rounded-lg p-4">
|
|
|
|
|
+ <p className="text-sm text-sub">Checking mode...</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : isDryRun ? (
|
|
|
|
|
+ <div className="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 rounded-lg p-4">
|
|
|
|
|
+ <p className="text-sm font-medium text-yellow-800 dark:text-yellow-400">
|
|
|
|
|
+ 🧪 DRY RUN mode — orders will be simulated, no real trades
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-lg p-4">
|
|
|
|
|
+ <p className="text-sm font-medium text-red-800 dark:text-red-400">
|
|
|
|
|
+ ⚠️ LIVE mode — real orders will be placed on Polymarket!
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ {selectedSignals.map((signal) => {
|
|
|
|
|
+ const priceData = prices.get(signal.id);
|
|
|
|
|
+ const side = sideLabels[signal.side];
|
|
|
|
|
+ let orderPrice: number;
|
|
|
|
|
+ if (orderMode === 'MARKET') {
|
|
|
|
|
+ orderPrice = signal.side === 'BUY_NO'
|
|
|
|
|
+ ? (priceData?.noBestAsk ?? (1 - signal.marketYesPrice))
|
|
|
|
|
+ : (priceData?.yesBestAsk ?? signal.marketYesPrice);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ orderPrice = signal.suggestedPrice;
|
|
|
|
|
+ }
|
|
|
|
|
+ const size = getSize(signal.id, signal.recommendedSize);
|
|
|
|
|
+ const cost = orderPrice * size;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={signal.id} className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-slate-700">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${side.bg} ${side.color}`}>
|
|
|
|
|
+ {side.text}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="text-sm text-heading font-medium">
|
|
|
|
|
+ {formatCity(signal.city)} · {signal.bucket}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p className="text-xs text-sub mt-0.5">
|
|
|
|
|
+ {orderMode === 'LIMIT' ? 'Limit' : 'Market'} @ {(orderPrice * 100).toFixed(1)}c
|
|
|
|
|
+ {' · '}{size.toFixed(1)} shares
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-right">
|
|
|
|
|
+ <p className="text-sm font-semibold text-heading">${cost.toFixed(2)}</p>
|
|
|
|
|
+ <p className="text-xs text-dim">cost</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Total summary */}
|
|
|
|
|
+ <div className="flex items-center justify-between p-4 rounded-lg bg-gray-50 dark:bg-slate-900/50 border border-gray-200 dark:border-slate-700">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p className="text-sm text-sub">Total Orders</p>
|
|
|
|
|
+ <p className="text-2xl font-bold text-heading">{selectedSignals.length}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-right">
|
|
|
|
|
+ <p className="text-sm text-sub">Total Cost</p>
|
|
|
|
|
+ <p className="text-2xl font-bold text-heading">
|
|
|
|
|
+ ${selectedSignals.reduce((sum, s) => {
|
|
|
|
|
+ const pd = prices.get(s.id);
|
|
|
|
|
+ let p: number;
|
|
|
|
|
+ if (orderMode === 'MARKET') {
|
|
|
|
|
+ p = s.side === 'BUY_NO' ? (pd?.noBestAsk ?? (1 - s.marketYesPrice)) : (pd?.yesBestAsk ?? s.marketYesPrice);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ p = s.suggestedPrice;
|
|
|
|
|
+ }
|
|
|
|
|
+ return sum + p * getSize(s.id, s.recommendedSize);
|
|
|
|
|
+ }, 0).toFixed(2)}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Step 4: Results */}
|
|
|
|
|
+ {step === 'result' && (
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ {results.map((result) => {
|
|
|
|
|
+ const signal = signals.find((s) => s.id === result.signalId);
|
|
|
|
|
+ const isSuccess = result.status === 'DRY_RUN' || result.status === 'SUBMITTED';
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={result.signalId}
|
|
|
|
|
+ className={`p-3 rounded-lg border ${
|
|
|
|
|
+ isSuccess
|
|
|
|
|
+ ? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/5'
|
|
|
|
|
+ : 'border-red-200 bg-red-50 dark:border-red-500/20 dark:bg-red-500/5'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <span className={isSuccess ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
|
|
|
|
|
+ {isSuccess ? '\u2713' : '\u2717'}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="text-sm font-medium text-heading">
|
|
|
|
|
+ {signal ? `${formatCity(signal.city)} · ${signal.bucket}` : result.signalId}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span className={`text-xs font-medium px-2 py-0.5 rounded ${
|
|
|
|
|
+ result.status === 'DRY_RUN'
|
|
|
|
|
+ ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'
|
|
|
|
|
+ : result.status === 'SUBMITTED'
|
|
|
|
|
+ ? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
|
|
|
|
|
+ : 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400'
|
|
|
|
|
+ }`}>
|
|
|
|
|
+ {result.status}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="mt-1 text-xs text-sub">
|
|
|
|
|
+ {result.side} @ {(result.price * 100).toFixed(1)}c
|
|
|
|
|
+ {' · '}{result.size.toFixed(1)} shares
|
|
|
|
|
+ {' · '}${result.costBasis.toFixed(2)}
|
|
|
|
|
+ {result.status === 'DRY_RUN' && (
|
|
|
|
|
+ <span className="ml-2 text-dim">ID: {result.orderId}</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {result.error && (
|
|
|
|
|
+ <p className="mt-1 text-xs text-red-600 dark:text-red-400">{result.error}</p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Footer actions */}
|
|
|
|
|
+ <div className="px-6 py-4 border-t border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ {step !== 'review' && step !== 'result' && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ if (step === 'pricing') setStep('review');
|
|
|
|
|
+ if (step === 'confirm') setStep('pricing');
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="btn-ghost btn-sm"
|
|
|
|
|
+ disabled={loading}
|
|
|
|
|
+ >
|
|
|
|
|
+ Back
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
|
|
+ {step === 'result' ? (
|
|
|
|
|
+ <button onClick={onComplete} className="btn-primary btn-sm">
|
|
|
|
|
+ Done
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <button onClick={onClose} className="btn-ghost btn-sm">
|
|
|
|
|
+ Cancel
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {step === 'review' && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={fetchPrices}
|
|
|
|
|
+ disabled={selectedIds.size === 0 || loading}
|
|
|
|
|
+ className="btn-primary btn-sm"
|
|
|
|
|
+ >
|
|
|
|
|
+ {loading ? (
|
|
|
|
|
+ <span className="flex items-center gap-2">
|
|
|
|
|
+ <Spinner /> Fetching Prices...
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ `Fetch Prices (${selectedIds.size})`
|
|
|
|
|
+ )}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {step === 'pricing' && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setStep('confirm')}
|
|
|
|
|
+ disabled={selectedIds.size === 0}
|
|
|
|
|
+ className="btn-primary btn-sm"
|
|
|
|
|
+ >
|
|
|
|
|
+ Review Orders ({selectedIds.size})
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {step === 'confirm' && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={placeOrders}
|
|
|
|
|
+ disabled={loading}
|
|
|
|
|
+ className={`btn-sm ${orderMode === 'MARKET' ? 'btn-danger' : 'btn-primary'}`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {loading ? (
|
|
|
|
|
+ <span className="flex items-center gap-2">
|
|
|
|
|
+ <Spinner /> Placing Orders...
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ `Place ${selectedIds.size} ${orderMode === 'LIMIT' ? 'Limit' : 'Market'} Order${selectedIds.size > 1 ? 's' : ''}`
|
|
|
|
|
+ )}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function Spinner() {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
|
|
|
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
|
|
|
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|