first commit
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
31
app/(shell)/dashboard/page.tsx
Normal file
31
app/(shell)/dashboard/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold">Dashboard</h1>
|
||||
<p className="text-sm text-black/60 dark:text-white/60">
|
||||
这里会展示:今日推荐(集合竞价/午盘/盘后)、自选股摘要、模拟盘摘要。
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-xl border border-black/10 p-4 dark:border-white/10">
|
||||
<div className="text-sm font-medium">集合竞价</div>
|
||||
<div className="mt-1 text-xs text-black/60 dark:text-white/60">
|
||||
今日盘前关注
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-black/10 p-4 dark:border-white/10">
|
||||
<div className="text-sm font-medium">午盘精选</div>
|
||||
<div className="mt-1 text-xs text-black/60 dark:text-white/60">
|
||||
盘中确认机会
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-black/10 p-4 dark:border-white/10">
|
||||
<div className="text-sm font-medium">盘后分析</div>
|
||||
<div className="mt-1 text-xs text-black/60 dark:text-white/60">
|
||||
复盘与次日预案
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
app/(shell)/layout.tsx
Normal file
61
app/(shell)/layout.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/watchlist", label: "自选股" },
|
||||
{ href: "/recommendations", label: "每日推荐" },
|
||||
{ href: "/trading", label: "模拟盘" },
|
||||
{ href: "/settings", label: "设置" },
|
||||
];
|
||||
|
||||
const quickLinks = [
|
||||
{ href: "/stocks/600519", label: "600519" },
|
||||
];
|
||||
|
||||
export default function ShellLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-background text-foreground">
|
||||
<div className="mx-auto flex w-full max-w-6xl gap-6 px-4 py-6">
|
||||
<aside className="hidden w-56 shrink-0 md:block">
|
||||
<div className="rounded-xl border border-black/10 bg-white/70 p-4 dark:border-white/10 dark:bg-black/30">
|
||||
<div className="text-sm font-semibold">Stock Web</div>
|
||||
<div className="mt-1 text-xs text-black/60 dark:text-white/60">
|
||||
选股系统(MVP)
|
||||
</div>
|
||||
<nav className="mt-4 flex flex-col gap-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="rounded-lg px-3 py-2 text-sm transition-colors hover:bg-black/[.04] dark:hover:bg-white/[.06]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 text-xs font-medium text-black/60 dark:text-white/60">
|
||||
快速打开
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{quickLinks.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="rounded-md border border-black/10 px-2 py-1 text-xs hover:bg-black/[.04] dark:border-white/10 dark:hover:bg-white/[.06]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1 rounded-xl border border-black/10 bg-white/70 p-5 dark:border-white/10 dark:bg-black/30">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
app/(shell)/recommendations/page.tsx
Normal file
214
app/(shell)/recommendations/page.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import type { RecommendationSegment } from "@/src/recommendations/types";
|
||||
import { useImportedPoolStore } from "@/src/stores/importedPoolStore";
|
||||
import { useRecommendationsStore } from "@/src/stores/recommendationsStore";
|
||||
|
||||
const segmentOptions: { value: RecommendationSegment; label: string }[] = [
|
||||
{ value: "OPEN_AUCTION", label: "集合竞价" },
|
||||
{ value: "MIDDAY", label: "午盘精选" },
|
||||
{ value: "AFTER_HOURS", label: "盘后分析" },
|
||||
];
|
||||
|
||||
export default function RecommendationsPage() {
|
||||
const codesText = useImportedPoolStore((s) => s.codesText);
|
||||
const poolLoading = useImportedPoolStore((s) => s.loading);
|
||||
const poolError = useImportedPoolStore((s) => s.error);
|
||||
const loadPool = useImportedPoolStore((s) => s.load);
|
||||
const savePool = useImportedPoolStore((s) => s.save);
|
||||
const setPoolDraft = useImportedPoolStore((s) => s.setDraft);
|
||||
|
||||
const date = useRecommendationsStore((s) => s.date);
|
||||
const segment = useRecommendationsStore((s) => s.segment);
|
||||
const recLoading = useRecommendationsStore((s) => s.loading);
|
||||
const recError = useRecommendationsStore((s) => s.error);
|
||||
const snapshot = useRecommendationsStore((s) => s.snapshot);
|
||||
const setDate = useRecommendationsStore((s) => s.setDate);
|
||||
const setSegment = useRecommendationsStore((s) => s.setSegment);
|
||||
const loadRec = useRecommendationsStore((s) => s.load);
|
||||
const generate = useRecommendationsStore((s) => s.generate);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPool();
|
||||
}, [loadPool]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRec();
|
||||
}, [date, segment, loadRec]);
|
||||
|
||||
const selectedSegmentLabel = useMemo(() => {
|
||||
return segmentOptions.find((x) => x.value === segment)?.label ?? segment;
|
||||
}, [segment]);
|
||||
|
||||
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:自选股 + 导入池 作为 Universe,规则 v0.1(突破+均线趋势),每段 Top 10。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-black/10 p-4 dark:border-white/10">
|
||||
<div className="grid gap-3 md:grid-cols-12">
|
||||
<label className="md:col-span-3">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">日期</div>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(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>
|
||||
|
||||
<label className="md:col-span-3">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">时段</div>
|
||||
<select
|
||||
value={segment}
|
||||
onChange={(e) => setSegment(e.target.value as RecommendationSegment)}
|
||||
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"
|
||||
>
|
||||
{segmentOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex items-end md:col-span-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={recLoading}
|
||||
onClick={() => void loadRec()}
|
||||
className="w-full rounded-lg border border-black/10 px-3 py-2 text-sm transition-colors hover:bg-black/[.04] disabled:opacity-50 dark:border-white/10 dark:hover:bg-white/[.06]"
|
||||
>
|
||||
读取快照
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end md:col-span-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={recLoading}
|
||||
onClick={async () => {
|
||||
// save pool first (so generated snapshot is reproducible)
|
||||
await savePool();
|
||||
await generate(codesText);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
生成 {selectedSegmentLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(poolError || recError) ? (
|
||||
<div className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||
{poolError ?? recError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(poolLoading || recLoading) ? (
|
||||
<div className="mt-3 text-xs text-black/60 dark:text-white/60">加载中…</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-black/10 p-4 dark:border-white/10">
|
||||
<div className="text-sm font-medium">导入池(Universe 扩展)</div>
|
||||
<p className="mt-1 text-xs text-black/60 dark:text-white/60">
|
||||
每行一个代码:支持 600519 或 SH:600519 / SZ:000001 / BJ:8xxxxx。空行会忽略。
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={codesText}
|
||||
onChange={(e) => setPoolDraft(e.target.value)}
|
||||
rows={6}
|
||||
className="mt-3 w-full rounded-lg border border-black/10 bg-white px-3 py-2 font-mono text-xs outline-none focus:border-black/30 dark:border-white/10 dark:bg-black/20 dark:focus:border-white/30"
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={poolLoading}
|
||||
onClick={() => void savePool()}
|
||||
className="rounded-lg border border-black/10 px-3 py-2 text-xs transition-colors hover:bg-black/[.04] disabled:opacity-50 dark:border-white/10 dark:hover:bg-white/[.06]"
|
||||
>
|
||||
保存导入池
|
||||
</button>
|
||||
</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>
|
||||
<div className="text-xs text-black/60 dark:text-white/60">
|
||||
{snapshot ? `版本 ${snapshot.screeningVersion} · ${snapshot.generatedAt}` : "(暂无快照)"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{snapshot ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">
|
||||
Universe:自选股 {snapshot.universe.watchlistCount} · 导入池 {snapshot.universe.importedPoolCount} · 合计 {snapshot.universe.totalCount}
|
||||
</div>
|
||||
|
||||
<div className="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">Score</th>
|
||||
<th className="py-2">理由</th>
|
||||
<th className="py-2">风险</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snapshot.items.map((it) => (
|
||||
<tr
|
||||
key={`${it.market}:${it.code}`}
|
||||
className="border-t border-black/5 dark:border-white/10"
|
||||
>
|
||||
<td className="py-2 text-xs text-black/60 dark:text-white/60">
|
||||
{it.rank}
|
||||
</td>
|
||||
<td className="py-2 font-mono">{it.code}</td>
|
||||
<td className="py-2">{it.market}</td>
|
||||
<td className="py-2">{it.score.toFixed(2)}</td>
|
||||
<td className="py-2 text-xs">
|
||||
<ul className="list-disc pl-4">
|
||||
{it.reasons.map((r, idx) => (
|
||||
<li key={idx}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
{it.risks.length === 0 ? (
|
||||
<span className="text-black/60 dark:text-white/60">-</span>
|
||||
) : (
|
||||
<ul className="list-disc pl-4">
|
||||
{it.risks.map((r, idx) => (
|
||||
<li key={idx}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-sm text-black/60 dark:text-white/60">
|
||||
还没有快照。点击右上角“生成”。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
app/(shell)/settings/page.tsx
Normal file
10
app/(shell)/settings/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold">设置</h1>
|
||||
<p className="text-sm text-black/60 dark:text-white/60">
|
||||
MVP:数据源/LLM 模式(Ollama/云端)与导入导出入口。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
app/(shell)/stocks/[code]/page.tsx
Normal file
62
app/(shell)/stocks/[code]/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { CandlesChart } from "@/src/components/charts/CandlesChart";
|
||||
import { mockGetDailyOhlcv, mockGetQuote } from "@/src/market/providers/mockProvider";
|
||||
|
||||
export default async function StockPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ code: string }>;
|
||||
}) {
|
||||
const { code } = await params;
|
||||
|
||||
const bars = mockGetDailyOhlcv(code, 200);
|
||||
const quote = mockGetQuote(code);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-black/60 dark:text-white/60">个股</div>
|
||||
<h1 className="text-xl font-semibold">
|
||||
<span className="font-mono">{code}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/watchlist"
|
||||
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]"
|
||||
>
|
||||
返回自选股
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-xl border border-black/10 p-4 dark:border-white/10">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">最新价</div>
|
||||
<div className="mt-1 text-lg font-semibold">{quote.price.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-black/10 p-4 dark:border-white/10">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">涨跌幅</div>
|
||||
<div className="mt-1 text-lg font-semibold">
|
||||
{quote.changePct != null ? `${quote.changePct.toFixed(2)}%` : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-black/10 p-4 dark:border-white/10">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">更新时间</div>
|
||||
<div className="mt-1 text-sm">{new Date(quote.asOf).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-black/10 p-4 dark:border-white/10">
|
||||
<div className="text-sm font-medium">日线K线(Mock 数据)</div>
|
||||
<div className="mt-3">
|
||||
<CandlesChart bars={bars} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-black/60 dark:text-white/60">
|
||||
注:当前为 Mock 行情。后续 M2 会替换为真实数据源(Next Route Handler 代理)。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
294
app/(shell)/trading/page.tsx
Normal file
294
app/(shell)/trading/page.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
205
app/(shell)/watchlist/page.tsx
Normal file
205
app/(shell)/watchlist/page.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { Market } from "@/src/domain/types";
|
||||
import { useWatchlistStore } from "@/src/stores/watchlistStore";
|
||||
|
||||
function normalizeCode(raw: string) {
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
export default function WatchlistPage() {
|
||||
const { items, loading, error, refresh, add, update, remove } =
|
||||
useWatchlistStore();
|
||||
|
||||
const [code, setCode] = useState("");
|
||||
const [market, setMarket] = useState<Market>("SZ");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return normalizeCode(code).length > 0;
|
||||
}, [code]);
|
||||
|
||||
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:添加/删除、备注、置顶(本地 IndexedDB 持久化,userId=anon)。
|
||||
</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-6"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
await add({
|
||||
code: normalizeCode(code),
|
||||
market,
|
||||
notes: notes.trim() || undefined,
|
||||
});
|
||||
|
||||
setCode("");
|
||||
setNotes("");
|
||||
}}
|
||||
>
|
||||
<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-2">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">备注</div>
|
||||
<input
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="可选"
|
||||
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-1">
|
||||
<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="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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="align-top">
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="py-6 text-xs text-black/60 dark:text-white/60"
|
||||
colSpan={6}
|
||||
>
|
||||
暂无自选股
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-t border-black/5 dark:border-white/10"
|
||||
>
|
||||
<td className="py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-black/10 px-2 py-1 text-xs dark:border-white/10"
|
||||
onClick={() =>
|
||||
void update(item.code, item.market, {
|
||||
pinned: !item.pinned,
|
||||
})
|
||||
}
|
||||
title="置顶"
|
||||
>
|
||||
{item.pinned ? "是" : "否"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 font-mono">{item.code}</td>
|
||||
<td className="py-2">{item.market}</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
defaultValue={item.notes ?? ""}
|
||||
placeholder="(空)"
|
||||
className="w-full rounded-md border border-black/10 bg-transparent px-2 py-1 text-xs outline-none focus:border-black/30 dark:border-white/10 dark:focus:border-white/30"
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if ((item.notes ?? "") !== v) {
|
||||
void update(item.code, item.market, {
|
||||
notes: v || undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-xs text-black/60 dark:text-white/60">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</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(item.code, item.market)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-3 text-xs text-black/60 dark:text-white/60">
|
||||
加载中…
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
app/globals.css
Normal file
26
app/globals.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
32
app/layout.tsx
Normal file
32
app/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Stock Web",
|
||||
description: "股票选股系统(MVP)",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6646
package-lock.json
generated
Normal file
6646
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "stock-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"dexie": "^4.3.0",
|
||||
"lightweight-charts": "^5.1.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
100
src/components/charts/CandlesChart.tsx
Normal file
100
src/components/charts/CandlesChart.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
CandlestickSeries,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
createChart,
|
||||
type UTCTimestamp,
|
||||
} from "lightweight-charts";
|
||||
|
||||
import type { OhlcvBar } from "@/src/market/types";
|
||||
|
||||
type Props = {
|
||||
bars: OhlcvBar[];
|
||||
height?: number;
|
||||
};
|
||||
|
||||
function toUtcTimestamp(date: string): UTCTimestamp {
|
||||
// date is YYYY-MM-DD
|
||||
const [y, m, d] = date.split("-").map((x) => Number(x));
|
||||
const ms = Date.UTC(y!, (m ?? 1) - 1, d ?? 1, 0, 0, 0);
|
||||
return Math.floor(ms / 1000) as UTCTimestamp;
|
||||
}
|
||||
|
||||
export function CandlesChart({ bars, height = 360 }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const seriesRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
||||
|
||||
const data = useMemo(() => {
|
||||
return bars.map((b) => ({
|
||||
time: toUtcTimestamp(b.time),
|
||||
open: b.open,
|
||||
high: b.high,
|
||||
low: b.low,
|
||||
close: b.close,
|
||||
}));
|
||||
}, [bars]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const el = containerRef.current;
|
||||
const chart = createChart(el, {
|
||||
autoSize: true,
|
||||
height,
|
||||
layout: {
|
||||
background: { color: "transparent" },
|
||||
textColor: "#6b7280",
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: "rgba(0,0,0,0.06)" },
|
||||
horzLines: { color: "rgba(0,0,0,0.06)" },
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderVisible: false,
|
||||
},
|
||||
timeScale: {
|
||||
borderVisible: false,
|
||||
},
|
||||
crosshair: {
|
||||
vertLine: { width: 1, color: "rgba(0,0,0,0.2)" },
|
||||
horzLine: { width: 1, color: "rgba(0,0,0,0.2)" },
|
||||
},
|
||||
});
|
||||
|
||||
const series = chart.addSeries(CandlestickSeries, {
|
||||
upColor: "#16a34a",
|
||||
downColor: "#dc2626",
|
||||
borderVisible: false,
|
||||
wickUpColor: "#16a34a",
|
||||
wickDownColor: "#dc2626",
|
||||
});
|
||||
|
||||
chartRef.current = chart;
|
||||
seriesRef.current = series;
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
chart.timeScale().fitContent();
|
||||
});
|
||||
ro.observe(el);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
seriesRef.current = null;
|
||||
};
|
||||
}, [height]);
|
||||
|
||||
useEffect(() => {
|
||||
const series = seriesRef.current;
|
||||
if (!series) return;
|
||||
series.setData(data);
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
}, [data]);
|
||||
|
||||
return <div ref={containerRef} className="w-full" />;
|
||||
}
|
||||
74
src/db/appDb.ts
Normal file
74
src/db/appDb.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import "client-only";
|
||||
|
||||
import Dexie, { type Table } from "dexie";
|
||||
|
||||
import type { WatchlistItem } from "@/src/domain/types";
|
||||
|
||||
export type WatchlistRow = WatchlistItem & { id: string };
|
||||
|
||||
export type TradeRow = {
|
||||
tradeId: string;
|
||||
userId: string;
|
||||
code: string;
|
||||
market: string;
|
||||
side: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
fees?: number;
|
||||
tradeAt: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type ImportedPoolRow = {
|
||||
id: string; // `${userId}:imported_pool`
|
||||
userId: string;
|
||||
codesText: string; // newline separated, with optional market prefix
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type RecommendationSnapshotRow = {
|
||||
id: string; // `${userId}:${date}:${segment}`
|
||||
userId: string;
|
||||
date: string;
|
||||
segment: string;
|
||||
generatedAt: string;
|
||||
screeningVersion: string;
|
||||
data: unknown; // full snapshot json
|
||||
};
|
||||
|
||||
export class AppDb extends Dexie {
|
||||
watchlist!: Table<WatchlistRow, string>; // primary key = id
|
||||
trades!: Table<TradeRow, string>; // primary key = tradeId
|
||||
importedPool!: Table<ImportedPoolRow, string>; // primary key = id
|
||||
recommendationSnapshots!: Table<RecommendationSnapshotRow, string>; // primary key = id
|
||||
|
||||
constructor() {
|
||||
super("stock_web_v1");
|
||||
|
||||
// v1: watchlist only
|
||||
this.version(1).stores({
|
||||
watchlist: "&id, userId, market, code, createdAt, pinned",
|
||||
});
|
||||
|
||||
// v2: add paper trading
|
||||
this.version(2).stores({
|
||||
watchlist: "&id, userId, market, code, createdAt, pinned",
|
||||
trades: "&tradeId, userId, market, code, tradeAt, side",
|
||||
});
|
||||
|
||||
// v3: add imported pool + recommendation snapshots
|
||||
this.version(3).stores({
|
||||
watchlist: "&id, userId, market, code, createdAt, pinned",
|
||||
trades: "&tradeId, userId, market, code, tradeAt, side",
|
||||
importedPool: "&id, userId, updatedAt",
|
||||
recommendationSnapshots:
|
||||
"&id, userId, date, segment, generatedAt, screeningVersion",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new AppDb();
|
||||
|
||||
export function watchlistKey(userId: string, market: string, code: string) {
|
||||
return `${userId}:${market}:${code}`;
|
||||
}
|
||||
16
src/domain/schemas.ts
Normal file
16
src/domain/schemas.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const MarketSchema = z.enum(["SH", "SZ", "BJ"]);
|
||||
|
||||
export const WatchlistItemSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
code: z.string().min(1),
|
||||
market: MarketSchema,
|
||||
createdAt: z.string().min(1),
|
||||
pinned: z.boolean().optional(),
|
||||
tags: z.array(z.string().min(1)).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export type WatchlistItemInput = z.input<typeof WatchlistItemSchema>;
|
||||
export type WatchlistItemParsed = z.output<typeof WatchlistItemSchema>;
|
||||
21
src/domain/types.ts
Normal file
21
src/domain/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type UserId = string;
|
||||
|
||||
export type Market = "SH" | "SZ" | "BJ";
|
||||
|
||||
export type StockCode = string;
|
||||
|
||||
export type Stock = {
|
||||
code: StockCode;
|
||||
market: Market;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type WatchlistItem = {
|
||||
userId: UserId;
|
||||
code: StockCode;
|
||||
market: Market;
|
||||
createdAt: string; // ISO
|
||||
pinned?: boolean;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
};
|
||||
90
src/market/providers/mockProvider.ts
Normal file
90
src/market/providers/mockProvider.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { OhlcvBar, Quote } from "@/src/market/types";
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
// deterministic PRNG
|
||||
let t = seed;
|
||||
return () => {
|
||||
t += 0x6d2b79f5;
|
||||
let r = Math.imul(t ^ (t >>> 15), 1 | t);
|
||||
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
|
||||
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function dateAddDays(date: Date, days: number) {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
function formatDate(d: Date) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function mockGetDailyOhlcv(
|
||||
code: string,
|
||||
bars = 200,
|
||||
endDate = new Date(),
|
||||
): OhlcvBar[] {
|
||||
const seed = [...code].reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||
const rnd = mulberry32(seed);
|
||||
|
||||
const start = dateAddDays(endDate, -bars - 20);
|
||||
|
||||
let price = 20 + rnd() * 50;
|
||||
const out: OhlcvBar[] = [];
|
||||
|
||||
for (let i = 0; i < bars; i++) {
|
||||
const day = dateAddDays(start, i);
|
||||
// skip weekends crudely
|
||||
const wd = day.getDay();
|
||||
if (wd === 0 || wd === 6) continue;
|
||||
|
||||
const drift = (rnd() - 0.48) * 0.02;
|
||||
const vol = 0.015 + rnd() * 0.03;
|
||||
|
||||
const open = price;
|
||||
const close = price * (1 + drift + (rnd() - 0.5) * vol);
|
||||
const high = Math.max(open, close) * (1 + rnd() * 0.02);
|
||||
const low = Math.min(open, close) * (1 - rnd() * 0.02);
|
||||
|
||||
price = close;
|
||||
|
||||
out.push({
|
||||
time: formatDate(day),
|
||||
open: round2(open),
|
||||
high: round2(high),
|
||||
low: round2(low),
|
||||
close: round2(close),
|
||||
volume: Math.floor(1000000 + rnd() * 5000000),
|
||||
});
|
||||
}
|
||||
|
||||
return out.slice(-bars);
|
||||
}
|
||||
|
||||
export function mockGetQuote(code: string): Quote {
|
||||
const bars = mockGetDailyOhlcv(code, 2);
|
||||
const last = bars[bars.length - 1];
|
||||
const prev = bars[bars.length - 2];
|
||||
|
||||
const price = last?.close ?? 0;
|
||||
const prevClose = prev?.close;
|
||||
const change = prevClose ? price - prevClose : undefined;
|
||||
const changePct = prevClose ? (change! / prevClose) * 100 : undefined;
|
||||
|
||||
return {
|
||||
price,
|
||||
prevClose,
|
||||
change,
|
||||
changePct,
|
||||
asOf: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function round2(n: number) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
16
src/market/types.ts
Normal file
16
src/market/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type OhlcvBar = {
|
||||
time: string; // YYYY-MM-DD
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume?: number;
|
||||
};
|
||||
|
||||
export type Quote = {
|
||||
price: number;
|
||||
prevClose?: number;
|
||||
change?: number;
|
||||
changePct?: number;
|
||||
asOf: string; // ISO
|
||||
};
|
||||
39
src/recommendations/types.ts
Normal file
39
src/recommendations/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Market } from "@/src/domain/types";
|
||||
|
||||
export type RecommendationSegment =
|
||||
| "OPEN_AUCTION"
|
||||
| "MIDDAY"
|
||||
| "AFTER_HOURS";
|
||||
|
||||
export type RecommendationSignal = {
|
||||
name: string;
|
||||
value: number | string;
|
||||
pass: boolean;
|
||||
};
|
||||
|
||||
export type RecommendationItem = {
|
||||
code: string;
|
||||
market: Market;
|
||||
rank: number;
|
||||
score: number;
|
||||
|
||||
reasons: string[];
|
||||
risks: string[];
|
||||
signals: RecommendationSignal[];
|
||||
};
|
||||
|
||||
export type RecommendationSnapshot = {
|
||||
userId: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
segment: RecommendationSegment;
|
||||
generatedAt: string; // ISO
|
||||
screeningVersion: string; // e.g. v0.1
|
||||
|
||||
universe: {
|
||||
watchlistCount: number;
|
||||
importedPoolCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
items: RecommendationItem[];
|
||||
};
|
||||
22
src/repositories/importedPoolRepo.ts
Normal file
22
src/repositories/importedPoolRepo.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { db, type ImportedPoolRow } from "@/src/db/appDb";
|
||||
|
||||
const DEFAULT_USER_ID = "anon";
|
||||
|
||||
function key(userId: string) {
|
||||
return `${userId}:imported_pool`;
|
||||
}
|
||||
|
||||
export async function getImportedPool(userId = DEFAULT_USER_ID) {
|
||||
return db.importedPool.get(key(userId));
|
||||
}
|
||||
|
||||
export async function setImportedPool(codesText: string, userId = DEFAULT_USER_ID) {
|
||||
const row: ImportedPoolRow = {
|
||||
id: key(userId),
|
||||
userId,
|
||||
codesText,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.importedPool.put(row);
|
||||
return row;
|
||||
}
|
||||
40
src/repositories/recommendationRepo.ts
Normal file
40
src/repositories/recommendationRepo.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { db, type RecommendationSnapshotRow } from "@/src/db/appDb";
|
||||
import type {
|
||||
RecommendationSegment,
|
||||
RecommendationSnapshot,
|
||||
} from "@/src/recommendations/types";
|
||||
|
||||
const DEFAULT_USER_ID = "anon";
|
||||
|
||||
function key(userId: string, date: string, segment: RecommendationSegment) {
|
||||
return `${userId}:${date}:${segment}`;
|
||||
}
|
||||
|
||||
export async function getRecommendationSnapshot(
|
||||
date: string,
|
||||
segment: RecommendationSegment,
|
||||
userId = DEFAULT_USER_ID,
|
||||
) {
|
||||
return db.recommendationSnapshots.get(key(userId, date, segment));
|
||||
}
|
||||
|
||||
export async function upsertRecommendationSnapshot(
|
||||
snapshot: RecommendationSnapshot,
|
||||
userId = DEFAULT_USER_ID,
|
||||
) {
|
||||
const row: RecommendationSnapshotRow = {
|
||||
id: key(userId, snapshot.date, snapshot.segment),
|
||||
userId,
|
||||
date: snapshot.date,
|
||||
segment: snapshot.segment,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
screeningVersion: snapshot.screeningVersion,
|
||||
data: snapshot,
|
||||
};
|
||||
await db.recommendationSnapshots.put(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function rowToSnapshot(row: RecommendationSnapshotRow): RecommendationSnapshot {
|
||||
return row.data as RecommendationSnapshot;
|
||||
}
|
||||
64
src/repositories/tradeRepo.ts
Normal file
64
src/repositories/tradeRepo.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { db, type TradeRow } from "@/src/db/appDb";
|
||||
import type { Market } from "@/src/domain/types";
|
||||
import type { Trade, TradeSide } from "@/src/trading/types";
|
||||
|
||||
const DEFAULT_USER_ID = "anon";
|
||||
|
||||
export type CreateTradeInput = {
|
||||
code: string;
|
||||
market: Market;
|
||||
side: TradeSide;
|
||||
quantity: number;
|
||||
price: number;
|
||||
fees?: number;
|
||||
tradeAt: string; // ISO
|
||||
note?: string;
|
||||
};
|
||||
|
||||
function newTradeId() {
|
||||
// good enough for local MVP
|
||||
return `t_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export async function listTrades(userId = DEFAULT_USER_ID) {
|
||||
return db.trades.where("userId").equals(userId).reverse().sortBy("tradeAt");
|
||||
}
|
||||
|
||||
export async function addTrade(input: CreateTradeInput, userId = DEFAULT_USER_ID) {
|
||||
const tradeId = newTradeId();
|
||||
|
||||
const row: TradeRow = {
|
||||
tradeId,
|
||||
userId,
|
||||
code: input.code,
|
||||
market: input.market,
|
||||
side: input.side,
|
||||
quantity: input.quantity,
|
||||
price: input.price,
|
||||
fees: input.fees,
|
||||
tradeAt: input.tradeAt,
|
||||
note: input.note,
|
||||
};
|
||||
|
||||
await db.trades.add(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function removeTrade(tradeId: string) {
|
||||
await db.trades.delete(tradeId);
|
||||
}
|
||||
|
||||
export function toTrade(row: TradeRow): Trade {
|
||||
return {
|
||||
userId: row.userId,
|
||||
tradeId: row.tradeId,
|
||||
code: row.code,
|
||||
market: row.market as Market,
|
||||
side: row.side as TradeSide,
|
||||
quantity: row.quantity,
|
||||
price: row.price,
|
||||
fees: row.fees,
|
||||
tradeAt: row.tradeAt,
|
||||
note: row.note,
|
||||
};
|
||||
}
|
||||
94
src/repositories/watchlistRepo.ts
Normal file
94
src/repositories/watchlistRepo.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { Market, WatchlistItem } from "@/src/domain/types";
|
||||
import { db, type WatchlistRow, watchlistKey } from "@/src/db/appDb";
|
||||
|
||||
const DEFAULT_USER_ID = "anon";
|
||||
|
||||
export type CreateWatchlistItemInput = {
|
||||
code: string;
|
||||
market: Market;
|
||||
pinned?: boolean;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type UpdateWatchlistItemInput = {
|
||||
pinned?: boolean;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export async function listWatchlist(userId = DEFAULT_USER_ID) {
|
||||
return db.watchlist
|
||||
.where("userId")
|
||||
.equals(userId)
|
||||
.toArray();
|
||||
}
|
||||
|
||||
export async function getWatchlistItem(
|
||||
code: string,
|
||||
market: Market,
|
||||
userId = DEFAULT_USER_ID,
|
||||
) {
|
||||
const id = watchlistKey(userId, market, code);
|
||||
return db.watchlist.get(id);
|
||||
}
|
||||
|
||||
export async function upsertWatchlistItem(
|
||||
input: CreateWatchlistItemInput,
|
||||
userId = DEFAULT_USER_ID,
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const id = watchlistKey(userId, input.market, input.code);
|
||||
|
||||
const existing = await db.watchlist.get(id);
|
||||
|
||||
const row: WatchlistRow = {
|
||||
id,
|
||||
userId,
|
||||
code: input.code,
|
||||
market: input.market,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
pinned: input.pinned ?? existing?.pinned,
|
||||
tags: input.tags ?? existing?.tags,
|
||||
notes: input.notes ?? existing?.notes,
|
||||
};
|
||||
|
||||
await db.watchlist.put(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updateWatchlistItem(
|
||||
code: string,
|
||||
market: Market,
|
||||
patch: UpdateWatchlistItemInput,
|
||||
userId = DEFAULT_USER_ID,
|
||||
) {
|
||||
const id = watchlistKey(userId, market, code);
|
||||
const existing = await db.watchlist.get(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const row: WatchlistRow = {
|
||||
...existing,
|
||||
pinned: patch.pinned ?? existing.pinned,
|
||||
tags: patch.tags ?? existing.tags,
|
||||
notes: patch.notes ?? existing.notes,
|
||||
};
|
||||
|
||||
await db.watchlist.put(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function removeWatchlistItem(
|
||||
code: string,
|
||||
market: Market,
|
||||
userId = DEFAULT_USER_ID,
|
||||
) {
|
||||
const id = watchlistKey(userId, market, code);
|
||||
await db.watchlist.delete(id);
|
||||
}
|
||||
|
||||
export function toWatchlistItem(row: WatchlistRow): WatchlistItem {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { id, ...rest } = row;
|
||||
return rest;
|
||||
}
|
||||
46
src/services/importedPool.ts
Normal file
46
src/services/importedPool.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Market } from "@/src/domain/types";
|
||||
|
||||
export type ParsedStockRef = { code: string; market: Market };
|
||||
|
||||
export function parseStockRef(raw: string): ParsedStockRef | null {
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
|
||||
// allow forms:
|
||||
// 600519
|
||||
// SH:600519
|
||||
// SZ:000001
|
||||
// BJ:xxxxxx
|
||||
const m = s.match(/^(?:(SH|SZ|BJ)[::])?(\d{6})$/i);
|
||||
if (!m) return null;
|
||||
|
||||
const market = (m[1]?.toUpperCase() as Market | undefined) ?? guessMarket(m[2]!);
|
||||
return { market, code: m[2]! };
|
||||
}
|
||||
|
||||
function guessMarket(code: string): Market {
|
||||
if (code.startsWith("6")) return "SH";
|
||||
if (code.startsWith("8") || code.startsWith("9")) return "BJ";
|
||||
return "SZ";
|
||||
}
|
||||
|
||||
export function normalizeImportedPool(text: string): ParsedStockRef[] {
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const out: ParsedStockRef[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
const ref = parseStockRef(line);
|
||||
if (!ref) continue;
|
||||
const key = `${ref.market}:${ref.code}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(ref);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
63
src/services/paperTrading.ts
Normal file
63
src/services/paperTrading.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Trade } from "@/src/trading/types";
|
||||
import type { Market } from "@/src/domain/types";
|
||||
import type { Position } from "@/src/trading/types";
|
||||
|
||||
export function computePositions(trades: Trade[]): Position[] {
|
||||
// Simple average-cost model.
|
||||
// BUY: increase qty, update avg cost.
|
||||
// SELL: decrease qty, realized pnl = (sellPrice - avgCost) * qty - fees.
|
||||
|
||||
const map = new Map<string, {
|
||||
market: Market;
|
||||
qty: number;
|
||||
avgCost: number;
|
||||
realizedPnl: number;
|
||||
}>();
|
||||
|
||||
for (const t of trades
|
||||
.slice()
|
||||
.sort((a, b) => a.tradeAt.localeCompare(b.tradeAt))) {
|
||||
const key = `${t.market}:${t.code}`;
|
||||
const cur = map.get(key) ?? {
|
||||
market: t.market,
|
||||
qty: 0,
|
||||
avgCost: 0,
|
||||
realizedPnl: 0,
|
||||
};
|
||||
|
||||
const fees = t.fees ?? 0;
|
||||
|
||||
if (t.side === "BUY") {
|
||||
const newQty = cur.qty + t.quantity;
|
||||
const totalCost = cur.avgCost * cur.qty + t.price * t.quantity + fees;
|
||||
cur.qty = newQty;
|
||||
cur.avgCost = newQty > 0 ? totalCost / newQty : 0;
|
||||
} else {
|
||||
const sellQty = t.quantity;
|
||||
const pnl = (t.price - cur.avgCost) * sellQty - fees;
|
||||
cur.qty = cur.qty - sellQty;
|
||||
cur.realizedPnl += pnl;
|
||||
|
||||
if (cur.qty <= 0) {
|
||||
// reset when flat/short (no shorting in MVP)
|
||||
cur.qty = 0;
|
||||
cur.avgCost = 0;
|
||||
}
|
||||
}
|
||||
|
||||
map.set(key, cur);
|
||||
}
|
||||
|
||||
return [...map.entries()]
|
||||
.map(([k, v]) => {
|
||||
const [market, code] = k.split(":") as [Market, string];
|
||||
return {
|
||||
market,
|
||||
code,
|
||||
quantity: v.qty,
|
||||
avgCost: v.avgCost,
|
||||
realizedPnl: v.realizedPnl,
|
||||
} satisfies Position;
|
||||
})
|
||||
.filter((p) => p.quantity > 0 || p.realizedPnl !== 0);
|
||||
}
|
||||
65
src/services/recommendations/indicators.ts
Normal file
65
src/services/recommendations/indicators.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { OhlcvBar } from "@/src/market/types";
|
||||
|
||||
export function simpleMovingAverage(values: number[], period: number) {
|
||||
if (period <= 0) return [] as number[];
|
||||
const out: number[] = [];
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
sum += values[i]!;
|
||||
if (i >= period) sum -= values[i - period]!;
|
||||
if (i >= period - 1) out.push(sum / period);
|
||||
else out.push(NaN);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeTrendSignals(bars: OhlcvBar[]) {
|
||||
// Use close prices only; MVP.
|
||||
const closes = bars.map((b) => b.close);
|
||||
const ma20 = simpleMovingAverage(closes, 20);
|
||||
const ma60 = simpleMovingAverage(closes, 60);
|
||||
|
||||
const lastIdx = closes.length - 1;
|
||||
const lastClose = closes[lastIdx];
|
||||
const lastMa20 = ma20[lastIdx];
|
||||
const lastMa60 = ma60[lastIdx];
|
||||
|
||||
const aboveMa20 = Number.isFinite(lastMa20) ? lastClose > lastMa20 : false;
|
||||
const aboveMa60 = Number.isFinite(lastMa60) ? lastClose > lastMa60 : false;
|
||||
|
||||
const ma20Up = slopeUp(ma20, 5);
|
||||
const ma60Up = slopeUp(ma60, 5);
|
||||
|
||||
const breakout20 = breakoutHigh(closes, 20);
|
||||
const breakout60 = breakoutHigh(closes, 60);
|
||||
|
||||
return {
|
||||
aboveMa20,
|
||||
aboveMa60,
|
||||
ma20Up,
|
||||
ma60Up,
|
||||
breakout20,
|
||||
breakout60,
|
||||
};
|
||||
}
|
||||
|
||||
function slopeUp(series: number[], lookback: number) {
|
||||
const n = series.length;
|
||||
if (n < lookback + 1) return false;
|
||||
|
||||
const a = series[n - 1];
|
||||
const b = series[n - 1 - lookback];
|
||||
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
|
||||
return a > b;
|
||||
}
|
||||
|
||||
function breakoutHigh(closes: number[], window: number) {
|
||||
if (closes.length < window + 1) return false;
|
||||
const last = closes[closes.length - 1]!;
|
||||
const prevWindow = closes.slice(-window - 1, -1);
|
||||
const prevHigh = Math.max(...prevWindow);
|
||||
return last > prevHigh;
|
||||
}
|
||||
146
src/services/recommendations/screening.ts
Normal file
146
src/services/recommendations/screening.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Market } from "@/src/domain/types";
|
||||
import { mockGetDailyOhlcv } from "@/src/market/providers/mockProvider";
|
||||
import type { OhlcvBar } from "@/src/market/types";
|
||||
import type {
|
||||
RecommendationItem,
|
||||
RecommendationSegment,
|
||||
RecommendationSnapshot,
|
||||
} from "@/src/recommendations/types";
|
||||
import { computeTrendSignals } from "@/src/services/recommendations/indicators";
|
||||
|
||||
export type StockRef = { market: Market; code: string };
|
||||
|
||||
export type ScreeningInput = {
|
||||
userId: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
segment: RecommendationSegment;
|
||||
universe: {
|
||||
watchlist: StockRef[];
|
||||
importedPool: StockRef[];
|
||||
};
|
||||
topN: number;
|
||||
};
|
||||
|
||||
export function runScreeningV01(input: ScreeningInput): RecommendationSnapshot {
|
||||
const all = dedupe([...input.universe.watchlist, ...input.universe.importedPool]);
|
||||
|
||||
const candidates = all
|
||||
.map((ref) => {
|
||||
const bars = mockGetDailyOhlcv(ref.code, 220);
|
||||
return scoreOne(ref, bars, input.segment);
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, input.topN)
|
||||
.map((it, idx) => ({ ...it, rank: idx + 1 }));
|
||||
|
||||
return {
|
||||
userId: input.userId,
|
||||
date: input.date,
|
||||
segment: input.segment,
|
||||
generatedAt: new Date().toISOString(),
|
||||
screeningVersion: "v0.1",
|
||||
universe: {
|
||||
watchlistCount: input.universe.watchlist.length,
|
||||
importedPoolCount: input.universe.importedPool.length,
|
||||
totalCount: all.length,
|
||||
},
|
||||
items: candidates,
|
||||
};
|
||||
}
|
||||
|
||||
function scoreOne(
|
||||
ref: StockRef,
|
||||
bars: OhlcvBar[],
|
||||
segment: RecommendationSegment,
|
||||
): RecommendationItem {
|
||||
const s = computeTrendSignals(bars);
|
||||
|
||||
// Baseline scoring: breakout + MA trend
|
||||
// Segment adjusts weights slightly.
|
||||
const w = segmentWeights(segment);
|
||||
|
||||
const signals = [
|
||||
{ name: "收盘>MA20", value: s.aboveMa20 ? 1 : 0, pass: s.aboveMa20 },
|
||||
{ name: "收盘>MA60", value: s.aboveMa60 ? 1 : 0, pass: s.aboveMa60 },
|
||||
{ name: "MA20上行", value: s.ma20Up ? 1 : 0, pass: s.ma20Up },
|
||||
{ name: "MA60上行", value: s.ma60Up ? 1 : 0, pass: s.ma60Up },
|
||||
{ name: "突破20日高", value: s.breakout20 ? 1 : 0, pass: s.breakout20 },
|
||||
{ name: "突破60日高", value: s.breakout60 ? 1 : 0, pass: s.breakout60 },
|
||||
];
|
||||
|
||||
const score =
|
||||
(s.aboveMa20 ? w.aboveMa20 : 0) +
|
||||
(s.aboveMa60 ? w.aboveMa60 : 0) +
|
||||
(s.ma20Up ? w.ma20Up : 0) +
|
||||
(s.ma60Up ? w.ma60Up : 0) +
|
||||
(s.breakout20 ? w.breakout20 : 0) +
|
||||
(s.breakout60 ? w.breakout60 : 0);
|
||||
|
||||
const reasons: string[] = [];
|
||||
if (s.breakout60) reasons.push("突破60日高(强势)");
|
||||
if (s.breakout20) reasons.push("突破20日高");
|
||||
if (s.aboveMa20 && s.ma20Up) reasons.push("站上MA20且MA20上行");
|
||||
if (s.aboveMa60 && s.ma60Up) reasons.push("站上MA60且MA60上行");
|
||||
if (reasons.length === 0) reasons.push("趋势信号一般(仅作为候选)");
|
||||
|
||||
const risks: string[] = [];
|
||||
if (!s.aboveMa20) risks.push("跌破MA20,短线可能走弱");
|
||||
if (!s.aboveMa60) risks.push("未站上MA60,中期趋势未确认");
|
||||
|
||||
return {
|
||||
code: ref.code,
|
||||
market: ref.market,
|
||||
rank: 0,
|
||||
score,
|
||||
reasons,
|
||||
risks,
|
||||
signals,
|
||||
};
|
||||
}
|
||||
|
||||
function segmentWeights(segment: RecommendationSegment) {
|
||||
switch (segment) {
|
||||
case "OPEN_AUCTION":
|
||||
return {
|
||||
aboveMa20: 1.2,
|
||||
aboveMa60: 1.0,
|
||||
ma20Up: 1.2,
|
||||
ma60Up: 0.8,
|
||||
breakout20: 1.0,
|
||||
breakout60: 1.2,
|
||||
};
|
||||
case "MIDDAY":
|
||||
return {
|
||||
aboveMa20: 1.0,
|
||||
aboveMa60: 1.2,
|
||||
ma20Up: 1.0,
|
||||
ma60Up: 1.0,
|
||||
breakout20: 1.0,
|
||||
breakout60: 1.4,
|
||||
};
|
||||
case "AFTER_HOURS":
|
||||
default:
|
||||
return {
|
||||
aboveMa20: 1.0,
|
||||
aboveMa60: 1.0,
|
||||
ma20Up: 1.0,
|
||||
ma60Up: 1.2,
|
||||
breakout20: 0.9,
|
||||
breakout60: 1.1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function dedupe(list: StockRef[]) {
|
||||
const out: StockRef[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const x of list) {
|
||||
const key = `${x.market}:${x.code}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(x);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
50
src/stores/importedPoolStore.ts
Normal file
50
src/stores/importedPoolStore.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
import { getImportedPool, setImportedPool } from "@/src/repositories/importedPoolRepo";
|
||||
|
||||
type ImportedPoolState = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
codesText: string;
|
||||
|
||||
setDraft: (codesText: string) => void;
|
||||
load: () => Promise<void>;
|
||||
save: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const useImportedPoolStore = create<ImportedPoolState>((set, get) => ({
|
||||
loading: false,
|
||||
error: null,
|
||||
codesText: "",
|
||||
|
||||
setDraft: (codesText) => set({ codesText }),
|
||||
|
||||
load: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const row = await getImportedPool();
|
||||
set({ codesText: row?.codesText ?? "", loading: false });
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "加载失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
save: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { codesText } = get();
|
||||
await setImportedPool(codesText);
|
||||
set({ loading: false });
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "保存失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
103
src/stores/recommendationsStore.ts
Normal file
103
src/stores/recommendationsStore.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
import type {
|
||||
RecommendationSegment,
|
||||
RecommendationSnapshot,
|
||||
} from "@/src/recommendations/types";
|
||||
import {
|
||||
getRecommendationSnapshot,
|
||||
rowToSnapshot,
|
||||
upsertRecommendationSnapshot,
|
||||
} from "@/src/repositories/recommendationRepo";
|
||||
import { normalizeImportedPool } from "@/src/services/importedPool";
|
||||
import { runScreeningV01 } from "@/src/services/recommendations/screening";
|
||||
import { listWatchlist } from "@/src/repositories/watchlistRepo";
|
||||
|
||||
const DEFAULT_USER_ID = "anon";
|
||||
|
||||
export type RecommendationViewState = {
|
||||
date: string; // YYYY-MM-DD
|
||||
segment: RecommendationSegment;
|
||||
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
snapshot: RecommendationSnapshot | null;
|
||||
|
||||
setDate: (date: string) => void;
|
||||
setSegment: (segment: RecommendationSegment) => void;
|
||||
|
||||
load: () => Promise<void>;
|
||||
generate: (importedCodesText: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function todayCN() {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export const useRecommendationsStore = create<RecommendationViewState>((set, get) => ({
|
||||
date: todayCN(),
|
||||
segment: "OPEN_AUCTION",
|
||||
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
snapshot: null,
|
||||
|
||||
setDate: (date) => set({ date }),
|
||||
setSegment: (segment) => set({ segment }),
|
||||
|
||||
load: async () => {
|
||||
const { date, segment } = get();
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const row = await getRecommendationSnapshot(date, segment, DEFAULT_USER_ID);
|
||||
set({ snapshot: row ? rowToSnapshot(row) : null, loading: false });
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "加载失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
generate: async (importedCodesText) => {
|
||||
const { date, segment } = get();
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const watchlist = await listWatchlist(DEFAULT_USER_ID);
|
||||
const watchlistRefs = watchlist.map((w) => ({
|
||||
market: w.market,
|
||||
code: w.code,
|
||||
}));
|
||||
|
||||
const importedRefs = normalizeImportedPool(importedCodesText);
|
||||
|
||||
const snapshot = runScreeningV01({
|
||||
userId: DEFAULT_USER_ID,
|
||||
date,
|
||||
segment,
|
||||
topN: 10,
|
||||
universe: {
|
||||
watchlist: watchlistRefs,
|
||||
importedPool: importedRefs,
|
||||
},
|
||||
});
|
||||
|
||||
await upsertRecommendationSnapshot(snapshot, DEFAULT_USER_ID);
|
||||
set({ snapshot, loading: false });
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "生成失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
71
src/stores/tradingStore.ts
Normal file
71
src/stores/tradingStore.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
import type { Market } from "@/src/domain/types";
|
||||
import type { TradeSide } from "@/src/trading/types";
|
||||
import {
|
||||
addTrade,
|
||||
listTrades,
|
||||
removeTrade,
|
||||
type CreateTradeInput,
|
||||
} from "@/src/repositories/tradeRepo";
|
||||
|
||||
export type TradeRow = Awaited<ReturnType<typeof listTrades>>[number];
|
||||
|
||||
type TradingState = {
|
||||
trades: TradeRow[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
refresh: () => Promise<void>;
|
||||
add: (input: CreateTradeInput) => Promise<void>;
|
||||
remove: (tradeId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useTradingStore = create<TradingState>((set, get) => ({
|
||||
trades: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
refresh: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const trades = await listTrades();
|
||||
set({ trades, loading: false });
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "加载失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
add: async (input) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await addTrade(input);
|
||||
await get().refresh();
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "新增成交失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
remove: async (tradeId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await removeTrade(tradeId);
|
||||
await get().refresh();
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "删除失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export type { Market, TradeSide };
|
||||
90
src/stores/watchlistStore.ts
Normal file
90
src/stores/watchlistStore.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
import type { Market } from "@/src/domain/types";
|
||||
import {
|
||||
listWatchlist,
|
||||
removeWatchlistItem,
|
||||
upsertWatchlistItem,
|
||||
updateWatchlistItem,
|
||||
type CreateWatchlistItemInput,
|
||||
type UpdateWatchlistItemInput,
|
||||
} from "@/src/repositories/watchlistRepo";
|
||||
|
||||
type WatchlistRow = Awaited<ReturnType<typeof listWatchlist>>[number];
|
||||
|
||||
type WatchlistState = {
|
||||
items: WatchlistRow[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
refresh: () => Promise<void>;
|
||||
add: (input: CreateWatchlistItemInput) => Promise<void>;
|
||||
update: (code: string, market: Market, patch: UpdateWatchlistItemInput) => Promise<void>;
|
||||
remove: (code: string, market: Market) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useWatchlistStore = create<WatchlistState>((set, get) => ({
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
refresh: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const items = await listWatchlist();
|
||||
items.sort((a, b) => {
|
||||
const ap = a.pinned ? 0 : 1;
|
||||
const bp = b.pinned ? 0 : 1;
|
||||
if (ap !== bp) return ap - bp;
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
});
|
||||
set({ items, loading: false });
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "加载失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
add: async (input) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await upsertWatchlistItem(input);
|
||||
await get().refresh();
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "添加失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
update: async (code, market, patch) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await updateWatchlistItem(code, market, patch);
|
||||
await get().refresh();
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "更新失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
remove: async (code, market) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await removeWatchlistItem(code, market);
|
||||
await get().refresh();
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "删除失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
27
src/trading/types.ts
Normal file
27
src/trading/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type TradeSide = "BUY" | "SELL";
|
||||
|
||||
export type Trade = {
|
||||
userId: string;
|
||||
tradeId: string;
|
||||
|
||||
code: string;
|
||||
market: "SH" | "SZ" | "BJ";
|
||||
|
||||
side: TradeSide;
|
||||
quantity: number;
|
||||
price: number;
|
||||
fees?: number;
|
||||
|
||||
tradeAt: string; // ISO
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type Position = {
|
||||
code: string;
|
||||
market: "SH" | "SZ" | "BJ";
|
||||
|
||||
quantity: number;
|
||||
avgCost: number;
|
||||
|
||||
realizedPnl: number;
|
||||
};
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user