Files
stock-web/app/(shell)/trading/page.tsx
2026-03-16 09:43:24 +08:00

295 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useState } from "react";
import type { Market } from "@/src/domain/types";
import type { Trade } from "@/src/trading/types";
import { computePositions } from "@/src/services/paperTrading";
import { toTrade } from "@/src/repositories/tradeRepo";
import { useTradingStore } from "@/src/stores/tradingStore";
function normalizeCode(raw: string) {
return raw.trim();
}
function toNumber(v: string) {
const n = Number(v);
return Number.isFinite(n) ? n : NaN;
}
export default function TradingPage() {
const { trades, loading, error, refresh, add, remove } = useTradingStore();
const [code, setCode] = useState("");
const [market, setMarket] = useState<Market>("SZ");
const [side, setSide] = useState<"BUY" | "SELL">("BUY");
const [quantity, setQuantity] = useState("100");
const [price, setPrice] = useState("10");
const [fees, setFees] = useState("0");
const [note, setNote] = useState("");
useEffect(() => {
void refresh();
}, [refresh]);
const tradesParsed: Trade[] = useMemo(() => {
return trades.map(toTrade);
}, [trades]);
const positions = useMemo(() => computePositions(tradesParsed), [tradesParsed]);
const qtyNum = useMemo(() => toNumber(quantity), [quantity]);
const priceNum = useMemo(() => toNumber(price), [price]);
const canSubmit = useMemo(() => {
return (
normalizeCode(code).length > 0 &&
qtyNum > 0 &&
priceNum > 0 &&
!Number.isNaN(qtyNum) &&
!Number.isNaN(priceNum)
);
}, [code, qtyNum, priceNum]);
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="mt-1 text-sm text-black/60 dark:text-white/60">
MVP/
</p>
</div>
<section className="rounded-xl border border-black/10 p-4 dark:border-white/10">
<div className="text-sm font-medium"></div>
<form
className="mt-3 grid gap-3 md:grid-cols-10"
onSubmit={async (e) => {
e.preventDefault();
if (!canSubmit) return;
await add({
code: normalizeCode(code),
market,
side,
quantity: qtyNum,
price: priceNum,
fees: fees.trim() ? toNumber(fees) : undefined,
tradeAt: new Date().toISOString(),
note: note.trim() || undefined,
});
setNote("");
}}
>
<label className="md:col-span-2">
<div className="text-xs text-black/60 dark:text-white/60"></div>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="例如600519"
className="mt-1 w-full rounded-lg border border-black/10 bg-white px-3 py-2 text-sm outline-none focus:border-black/30 dark:border-white/10 dark:bg-black/20 dark:focus:border-white/30"
/>
</label>
<label className="md:col-span-1">
<div className="text-xs text-black/60 dark:text-white/60"></div>
<select
value={market}
onChange={(e) => setMarket(e.target.value as Market)}
className="mt-1 w-full rounded-lg border border-black/10 bg-white px-3 py-2 text-sm outline-none focus:border-black/30 dark:border-white/10 dark:bg-black/20 dark:focus:border-white/30"
>
<option value="SZ">SZ</option>
<option value="SH">SH</option>
<option value="BJ">BJ</option>
</select>
</label>
<label className="md:col-span-1">
<div className="text-xs text-black/60 dark:text-white/60"></div>
<select
value={side}
onChange={(e) => setSide(e.target.value as "BUY" | "SELL")}
className="mt-1 w-full rounded-lg border border-black/10 bg-white px-3 py-2 text-sm outline-none focus:border-black/30 dark:border-white/10 dark:bg-black/20 dark:focus:border-white/30"
>
<option value="BUY"></option>
<option value="SELL"></option>
</select>
</label>
<label className="md:col-span-2">
<div className="text-xs text-black/60 dark:text-white/60"></div>
<input
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
inputMode="numeric"
className="mt-1 w-full rounded-lg border border-black/10 bg-white px-3 py-2 text-sm outline-none focus:border-black/30 dark:border-white/10 dark:bg-black/20 dark:focus:border-white/30"
/>
</label>
<label className="md:col-span-2">
<div className="text-xs text-black/60 dark:text-white/60"></div>
<input
value={price}
onChange={(e) => setPrice(e.target.value)}
inputMode="decimal"
className="mt-1 w-full rounded-lg border border-black/10 bg-white px-3 py-2 text-sm outline-none focus:border-black/30 dark:border-white/10 dark:bg-black/20 dark:focus:border-white/30"
/>
</label>
<label className="md:col-span-2">
<div className="text-xs text-black/60 dark:text-white/60">()</div>
<input
value={fees}
onChange={(e) => setFees(e.target.value)}
inputMode="decimal"
className="mt-1 w-full rounded-lg border border-black/10 bg-white px-3 py-2 text-sm outline-none focus:border-black/30 dark:border-white/10 dark:bg-black/20 dark:focus:border-white/30"
/>
</label>
<label className="md:col-span-8">
<div className="text-xs text-black/60 dark:text-white/60">()</div>
<input
value={note}
onChange={(e) => setNote(e.target.value)}
className="mt-1 w-full rounded-lg border border-black/10 bg-white px-3 py-2 text-sm outline-none focus:border-black/30 dark:border-white/10 dark:bg-black/20 dark:focus:border-white/30"
/>
</label>
<div className="flex items-end md:col-span-2">
<button
type="submit"
disabled={!canSubmit || loading}
className="w-full rounded-lg bg-black px-3 py-2 text-sm font-medium text-white transition-opacity disabled:opacity-50 dark:bg-white dark:text-black"
>
</button>
</div>
</form>
{error ? (
<div className="mt-3 text-sm text-red-600 dark:text-red-400">
{error}
</div>
) : null}
</section>
<section className="rounded-xl border border-black/10 p-4 dark:border-white/10">
<div className="text-sm font-medium"></div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs text-black/60 dark:text-white/60">
<tr>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{positions.length === 0 ? (
<tr>
<td
className="py-6 text-xs text-black/60 dark:text-white/60"
colSpan={5}
>
</td>
</tr>
) : null}
{positions.map((p) => (
<tr
key={`${p.market}:${p.code}`}
className="border-t border-black/5 dark:border-white/10"
>
<td className="py-2 font-mono">{p.code}</td>
<td className="py-2">{p.market}</td>
<td className="py-2">{p.quantity}</td>
<td className="py-2">{p.avgCost.toFixed(3)}</td>
<td className="py-2">{p.realizedPnl.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="rounded-xl border border-black/10 p-4 dark:border-white/10">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium"></div>
<button
type="button"
onClick={() => void refresh()}
className="rounded-lg border border-black/10 px-3 py-2 text-xs transition-colors hover:bg-black/[.04] dark:border-white/10 dark:hover:bg-white/[.06]"
>
</button>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs text-black/60 dark:text-white/60">
<tr>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{trades.length === 0 ? (
<tr>
<td
className="py-6 text-xs text-black/60 dark:text-white/60"
colSpan={9}
>
</td>
</tr>
) : null}
{trades.map((t) => (
<tr key={t.tradeId} className="border-t border-black/5 dark:border-white/10">
<td className="py-2 text-xs text-black/60 dark:text-white/60">
{new Date(t.tradeAt).toLocaleString()}
</td>
<td className="py-2 font-mono">{t.code}</td>
<td className="py-2">{t.market}</td>
<td className="py-2">{t.side === "BUY" ? "买" : "卖"}</td>
<td className="py-2">{t.quantity}</td>
<td className="py-2">{t.price}</td>
<td className="py-2">{t.fees ?? 0}</td>
<td className="py-2 text-xs">{t.note ?? ""}</td>
<td className="py-2">
<button
type="button"
className="rounded-md border border-black/10 px-2 py-1 text-xs transition-colors hover:bg-black/[.04] dark:border-white/10 dark:hover:bg-white/[.06]"
onClick={() => void remove(t.tradeId)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{loading ? (
<div className="mt-3 text-xs text-black/60 dark:text-white/60">
</div>
) : null}
</section>
</div>
);
}