feat(api): 重构资产录入新增 exchange 字段,并接入腾讯财经 qt.gtimg 极速行情引擎
This commit is contained in:
parent
ce529928cc
commit
842a5fef8c
@ -9,6 +9,7 @@ import { z } from 'zod';
|
|||||||
const createAssetSchema = z.object({
|
const createAssetSchema = z.object({
|
||||||
symbol: z.string().min(1, 'Symbol is required'),
|
symbol: z.string().min(1, 'Symbol is required'),
|
||||||
type: z.enum(['STOCK', 'CRYPTO', 'CASH']),
|
type: z.enum(['STOCK', 'CRYPTO', 'CASH']),
|
||||||
|
exchange: z.string().optional(),
|
||||||
baseCurrency: z.string().min(2).max(10),
|
baseCurrency: z.string().min(2).max(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -56,4 +57,4 @@ export async function updateAssetPrice(params: z.infer<typeof updatePriceSchema>
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,13 +5,16 @@ import { assets } from '@/db/schema';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
const API_KEY = process.env.ALPHA_VANTAGE_API_KEY;
|
export function getTencentSymbol(asset: { symbol: string; exchange: string | null }): string {
|
||||||
|
const cleanSymbol = asset.symbol.trim().toUpperCase().replace(/[^0-9A-Z]/g, '');
|
||||||
|
|
||||||
function generateRandomPrice(currentPrice: string): string {
|
switch (asset.exchange) {
|
||||||
const price = parseFloat(currentPrice);
|
case 'SSE': return 'sh' + cleanSymbol;
|
||||||
const changePercent = (Math.random() * 4 - 2);
|
case 'SZSE': return 'sz' + cleanSymbol;
|
||||||
const newPrice = price * (1 + changePercent / 100);
|
case 'HKEX': return 'hk' + cleanSymbol;
|
||||||
return newPrice.toFixed(2);
|
case 'US':
|
||||||
|
default: return 's_us' + cleanSymbol;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncAllStockPrices() {
|
export async function syncAllStockPrices() {
|
||||||
@ -24,30 +27,24 @@ export async function syncAllStockPrices() {
|
|||||||
|
|
||||||
for (const asset of stockAssets) {
|
for (const asset of stockAssets) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=${asset.symbol}&apikey=${API_KEY}`, { cache: 'no-store' });
|
const tCode = getTencentSymbol(asset);
|
||||||
const data = await response.json();
|
const response = await fetch(`https://qt.gtimg.cn/q=${tCode}`, { cache: 'no-store' });
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
if (data['Information'] || data['Note'] || !data['Global Quote']) {
|
const match = text.match(/="([^"]+)"/);
|
||||||
throw new Error('Alpha Vantage API 达到频率限制或未找到该股票');
|
if (match && match[1]) {
|
||||||
}
|
const dataArr = match[1].split('~');
|
||||||
|
const latestPrice = dataArr[3];
|
||||||
|
|
||||||
const priceString = data['Global Quote']['05. price'];
|
if (latestPrice && !isNaN(Number(latestPrice)) && Number(latestPrice) > 0) {
|
||||||
if (priceString) {
|
await db.update(assets)
|
||||||
await db
|
.set({ latestPrice: latestPrice })
|
||||||
.update(assets)
|
.where(eq(assets.id, asset.id));
|
||||||
.set({ latestPrice: priceString })
|
successCount++;
|
||||||
.where(eq(assets.id, asset.id));
|
}
|
||||||
successCount++;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to fetch price for ${asset.symbol}:`, error);
|
console.warn(`[行情引擎] 同步 ${asset.symbol} 失败,保持原价:`, error);
|
||||||
const currentPrice = asset.latestPrice || '0.00';
|
|
||||||
const simulatedPrice = generateRandomPrice(currentPrice);
|
|
||||||
await db
|
|
||||||
.update(assets)
|
|
||||||
.set({ latestPrice: simulatedPrice })
|
|
||||||
.where(eq(assets.id, asset.id));
|
|
||||||
console.warn(`[熔断] ${asset.symbol} 使用波动模拟器更新价格为 ${simulatedPrice}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -37,11 +37,19 @@ import { createAsset } from '@/actions/asset';
|
|||||||
const addAssetSchema = z.object({
|
const addAssetSchema = z.object({
|
||||||
symbol: z.string().min(1, '资产代码不能为空'),
|
symbol: z.string().min(1, '资产代码不能为空'),
|
||||||
type: z.enum(['STOCK', 'CRYPTO', 'CASH']),
|
type: z.enum(['STOCK', 'CRYPTO', 'CASH']),
|
||||||
|
exchange: z.string().optional(),
|
||||||
baseCurrency: z.string().min(2, '基础币种至少2个字符'),
|
baseCurrency: z.string().min(2, '基础币种至少2个字符'),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddAssetForm = z.infer<typeof addAssetSchema>;
|
type AddAssetForm = z.infer<typeof addAssetSchema>;
|
||||||
|
|
||||||
|
const EXCHANGE_OPTIONS = [
|
||||||
|
{ value: 'US', label: '美股 (US)' },
|
||||||
|
{ value: 'HKEX', label: '港股 (HKEX)' },
|
||||||
|
{ value: 'SSE', label: '沪市 A 股 (SSE)' },
|
||||||
|
{ value: 'SZSE', label: '深市 A 股 (SZSE)' },
|
||||||
|
];
|
||||||
|
|
||||||
export function AddAssetDialog() {
|
export function AddAssetDialog() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@ -52,6 +60,7 @@ export function AddAssetDialog() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
symbol: '',
|
symbol: '',
|
||||||
type: 'STOCK' as const,
|
type: 'STOCK' as const,
|
||||||
|
exchange: 'US',
|
||||||
baseCurrency: '',
|
baseCurrency: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -117,6 +126,30 @@ export function AddAssetDialog() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="exchange"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>交易所 / 市场 (Exchange)</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择交易所" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{EXCHANGE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="baseCurrency"
|
name="baseCurrency"
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export const assets = pgTable("assets", {
|
|||||||
id: uuid("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: uuid("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
symbol: varchar("symbol", { length: 20 }).notNull().unique(),
|
symbol: varchar("symbol", { length: 20 }).notNull().unique(),
|
||||||
type: assetTypeEnum("type").notNull(),
|
type: assetTypeEnum("type").notNull(),
|
||||||
|
exchange: varchar("exchange", { length: 10 }).default('US'),
|
||||||
baseCurrency: varchar("base_currency", { length: 10 }).notNull(),
|
baseCurrency: varchar("base_currency", { length: 10 }).notNull(),
|
||||||
latestPrice: numeric("latest_price", { precision: 36, scale: 18 }).default('0').notNull(),
|
latestPrice: numeric("latest_price", { precision: 36, scale: 18 }).default('0').notNull(),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" })
|
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" })
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user