feat: add scenario analysis workspace
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
||||
createIngestionRun,
|
||||
createRawArtifact,
|
||||
createAreaScoreModelRun,
|
||||
createScenarioRun,
|
||||
createWatchlistEvent,
|
||||
createWatchlistItem,
|
||||
fetchDataSources,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
fetchNeighborhoodDetail,
|
||||
fetchNeighborhoodScores,
|
||||
fetchRawArtifacts,
|
||||
fetchScenarioRuns,
|
||||
fetchWatchlistEvents,
|
||||
fetchWatchlistItems,
|
||||
finishIngestionRun,
|
||||
@@ -65,6 +67,7 @@ import type {
|
||||
CreateDataSource,
|
||||
CreateIngestionRun,
|
||||
CreateRawArtifact,
|
||||
CreateScenarioRun,
|
||||
CreateWatchlistEvent,
|
||||
CreateWatchlistItem,
|
||||
DataSource,
|
||||
@@ -75,6 +78,8 @@ import type {
|
||||
NeighborhoodDetail,
|
||||
NeighborhoodScore,
|
||||
RawArtifact,
|
||||
ScenarioRun,
|
||||
ScenarioRunResponse,
|
||||
SimilarNeighborhood,
|
||||
UpdateWatchlistItem,
|
||||
WatchlistEvent,
|
||||
@@ -108,6 +113,7 @@ export function MarketDashboard() {
|
||||
const [expandedWatchlistItemId, setExpandedWatchlistItemId] = useState<number | null>(null);
|
||||
const [baseModelRunId, setBaseModelRunId] = useState<number | null>(null);
|
||||
const [candidateModelRunId, setCandidateModelRunId] = useState<number | null>(null);
|
||||
const [latestScenario, setLatestScenario] = useState<ScenarioRunResponse | null>(null);
|
||||
const overview = useQuery({
|
||||
queryKey: ["market-overview", MONTH],
|
||||
queryFn: () => fetchMarketOverview(MONTH),
|
||||
@@ -166,6 +172,10 @@ export function MarketDashboard() {
|
||||
candidateModelRunId !== null &&
|
||||
baseModelRunId !== candidateModelRunId,
|
||||
});
|
||||
const scenarioRuns = useQuery({
|
||||
queryKey: ["scenario-runs", MONTH],
|
||||
queryFn: () => fetchScenarioRuns({ target_month: MONTH }),
|
||||
});
|
||||
const dataSources = useQuery({
|
||||
queryKey: ["data-sources"],
|
||||
queryFn: fetchDataSources,
|
||||
@@ -227,6 +237,13 @@ export function MarketDashboard() {
|
||||
]);
|
||||
},
|
||||
});
|
||||
const createScenarioMutation = useMutation({
|
||||
mutationFn: createScenarioRun,
|
||||
onSuccess: async (response) => {
|
||||
setLatestScenario(response);
|
||||
await queryClient.invalidateQueries({ queryKey: ["scenario-runs"] });
|
||||
},
|
||||
});
|
||||
const createDataSourceMutation = useMutation({
|
||||
mutationFn: createDataSource,
|
||||
onSuccess: async () => {
|
||||
@@ -303,6 +320,7 @@ export function MarketDashboard() {
|
||||
watchlistEvents.isLoading ||
|
||||
modelRuns.isLoading ||
|
||||
areaScoreRunDiff.isLoading ||
|
||||
scenarioRuns.isLoading ||
|
||||
dataSources.isLoading ||
|
||||
ingestionRuns.isLoading;
|
||||
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
||||
@@ -318,6 +336,7 @@ export function MarketDashboard() {
|
||||
watchlistEvents.isError ||
|
||||
modelRuns.isError ||
|
||||
areaScoreRunDiff.isError ||
|
||||
scenarioRuns.isError ||
|
||||
dataSources.isError ||
|
||||
ingestionRuns.isError ||
|
||||
rawArtifacts.isError;
|
||||
@@ -350,6 +369,7 @@ export function MarketDashboard() {
|
||||
void rawArtifacts.refetch();
|
||||
void modelRuns.refetch();
|
||||
void areaScoreRunDiff.refetch();
|
||||
void scenarioRuns.refetch();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
aria-label="刷新"
|
||||
@@ -517,6 +537,15 @@ export function MarketDashboard() {
|
||||
onArchive={(id) => archiveMutation.mutate(id)}
|
||||
/>
|
||||
|
||||
<ScenarioWorkspace
|
||||
areaScores={scores.data ?? []}
|
||||
runs={scenarioRuns.data ?? []}
|
||||
response={latestScenario}
|
||||
loading={scenarioRuns.isLoading}
|
||||
creating={createScenarioMutation.isPending}
|
||||
onCreate={(payload) => createScenarioMutation.mutate(payload)}
|
||||
/>
|
||||
|
||||
<ModelRunPanel
|
||||
runs={modelRuns.data ?? []}
|
||||
diff={areaScoreRunDiff.data ?? null}
|
||||
@@ -2143,6 +2172,327 @@ function formatSimilarityReason(reason: string) {
|
||||
return reason;
|
||||
}
|
||||
|
||||
function ScenarioWorkspace({
|
||||
areaScores,
|
||||
runs,
|
||||
response,
|
||||
loading,
|
||||
creating,
|
||||
onCreate,
|
||||
}: {
|
||||
areaScores: AreaScore[];
|
||||
runs: ScenarioRun[];
|
||||
response: ScenarioRunResponse | null;
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
onCreate: (payload: CreateScenarioRun) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("利率与供应压力测试");
|
||||
const [areaId, setAreaId] = useState("");
|
||||
const [interestRateDeltaBps, setInterestRateDeltaBps] = useState(35);
|
||||
const [policySignalDelta, setPolicySignalDelta] = useState(-1);
|
||||
const [supplyDeltaPct, setSupplyDeltaPct] = useState(20);
|
||||
const [rentDeltaPct, setRentDeltaPct] = useState(2);
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
function submitScenario() {
|
||||
onCreate({
|
||||
name,
|
||||
target_month: MONTH,
|
||||
area_id: areaId || undefined,
|
||||
interest_rate_delta_bps: interestRateDeltaBps,
|
||||
policy_signal_delta: policySignalDelta,
|
||||
supply_delta_pct: supplyDeltaPct,
|
||||
rent_delta_pct: rentDeltaPct,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<CardTitle>情景推演</CardTitle>
|
||||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||
保存假设并生成谨慎、基准、乐观三档结果
|
||||
</p>
|
||||
</div>
|
||||
{response ? (
|
||||
<Badge variant="success">最近:{response.run.name}</Badge>
|
||||
) : (
|
||||
<Badge variant="muted">{MONTH}</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="grid gap-3 rounded-md border border-[var(--border)] p-4">
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-950">情景名称</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-950">作用范围</span>
|
||||
<select
|
||||
value={areaId}
|
||||
onChange={(event) => setAreaId(event.target.value)}
|
||||
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||
>
|
||||
<option value="">全市场板块</option>
|
||||
{areaScores.map((score) => (
|
||||
<option key={score.area_id} value={score.area_id}>
|
||||
{score.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<ScenarioNumberInput
|
||||
label="利率变化"
|
||||
value={interestRateDeltaBps}
|
||||
min={-300}
|
||||
max={300}
|
||||
step={5}
|
||||
suffix="bp"
|
||||
onChange={setInterestRateDeltaBps}
|
||||
/>
|
||||
<ScenarioNumberInput
|
||||
label="政策信号"
|
||||
value={policySignalDelta}
|
||||
min={-4}
|
||||
max={4}
|
||||
step={1}
|
||||
suffix=""
|
||||
onChange={setPolicySignalDelta}
|
||||
/>
|
||||
<ScenarioNumberInput
|
||||
label="新增供应"
|
||||
value={supplyDeltaPct}
|
||||
min={-100}
|
||||
max={500}
|
||||
step={5}
|
||||
suffix="%"
|
||||
onChange={setSupplyDeltaPct}
|
||||
/>
|
||||
<ScenarioNumberInput
|
||||
label="租金变化"
|
||||
value={rentDeltaPct}
|
||||
min={-80}
|
||||
max={200}
|
||||
step={1}
|
||||
suffix="%"
|
||||
onChange={setRentDeltaPct}
|
||||
/>
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-950">备注</span>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
className="min-h-20 rounded-md border border-[var(--border)] px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<Button onClick={submitScenario} disabled={creating || !name.trim()}>
|
||||
{creating ? "计算中" : "保存并推演"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{response ? <ScenarioResult response={response} /> : null}
|
||||
{!response ? <ScenarioRunHistory runs={runs} loading={loading} /> : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ScenarioNumberInput({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
suffix,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
suffix: string;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="flex items-center justify-between">
|
||||
<span className="font-medium text-slate-950">{label}</span>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{formatSignedNumber(value)}
|
||||
{suffix}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ScenarioResult({ response }: { response: ScenarioRunResponse }) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{response.variants.map((variant) => (
|
||||
<div key={variant.key} className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||
<div className="flex flex-col gap-2 border-b border-[var(--border)] px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-950">{variant.name}</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{variant.assumptions.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={scenarioVariantBadge(variant.key)}>{variant.items.length} 个板块</Badge>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>板块</TableHead>
|
||||
<TableHead className="text-right">分数变化</TableHead>
|
||||
<TableHead className="text-right">价格变化</TableHead>
|
||||
<TableHead className="text-right">租金收益率</TableHead>
|
||||
<TableHead className="text-right">流动性</TableHead>
|
||||
<TableHead className="text-right">供应风险</TableHead>
|
||||
<TableHead>风险解释</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{variant.items.map((item) => (
|
||||
<TableRow key={`${variant.key}-${item.area_id}`}>
|
||||
<TableCell className="min-w-32">
|
||||
<span className="block font-medium text-slate-950">{item.name}</span>
|
||||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||
{item.district} · {item.recommendation}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="block font-medium">
|
||||
{formatNumber(item.projected_score)}
|
||||
</span>
|
||||
<span className={scenarioDeltaClass(item.score_delta)}>
|
||||
{formatSignedNumber(item.score_delta)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="block">{formatPrice(item.projected_price_psm)}</span>
|
||||
<span className={scenarioDeltaClass(item.price_delta_pct)}>
|
||||
{formatSignedNumber(item.price_delta_pct)}%
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(item.projected_rent_yield_pct, 2)}%
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(item.projected_liquidity_score)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(item.projected_supply_risk_score)}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-72 text-sm text-[var(--muted-foreground)]">
|
||||
{item.risk_notes.join(";")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScenarioRunHistory({ runs, loading }: { runs: ScenarioRun[]; loading: boolean }) {
|
||||
if (loading) {
|
||||
return <div className="h-64 rounded-md bg-[var(--muted)]" />;
|
||||
}
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||||
暂无已保存情景
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>已保存情景</TableHead>
|
||||
<TableHead>月份</TableHead>
|
||||
<TableHead className="text-right">利率</TableHead>
|
||||
<TableHead className="text-right">政策</TableHead>
|
||||
<TableHead className="text-right">供应</TableHead>
|
||||
<TableHead className="text-right">租金</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.slice(0, 6).map((run) => (
|
||||
<TableRow key={run.scenario_run_id}>
|
||||
<TableCell className="font-medium">{run.name}</TableCell>
|
||||
<TableCell>{run.target_month}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatSignedNumber(run.interest_rate_delta_bps)}bp
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatSignedNumber(run.policy_signal_delta)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatSignedNumber(run.supply_delta_pct)}%
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatSignedNumber(run.rent_delta_pct)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function scenarioVariantBadge(key: string): "success" | "warning" | "muted" {
|
||||
if (key === "upside") {
|
||||
return "success";
|
||||
}
|
||||
if (key === "downside") {
|
||||
return "warning";
|
||||
}
|
||||
return "muted";
|
||||
}
|
||||
|
||||
function scenarioDeltaClass(value: number) {
|
||||
if (value > 0) {
|
||||
return "text-xs font-medium text-teal-700";
|
||||
}
|
||||
if (value < 0) {
|
||||
return "text-xs font-medium text-rose-700";
|
||||
}
|
||||
return "text-xs font-medium text-[var(--muted-foreground)]";
|
||||
}
|
||||
|
||||
function WatchlistPanel({
|
||||
scores,
|
||||
neighborhoodScores,
|
||||
|
||||
@@ -415,6 +415,59 @@ export type SimilarNeighborhoodResponse = {
|
||||
items: SimilarNeighborhood[];
|
||||
};
|
||||
|
||||
export type ScenarioRun = {
|
||||
scenario_run_id: number;
|
||||
name: string;
|
||||
target_month: string;
|
||||
area_id: string | null;
|
||||
interest_rate_delta_bps: number;
|
||||
policy_signal_delta: number;
|
||||
supply_delta_pct: number;
|
||||
rent_delta_pct: number;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CreateScenarioRun = {
|
||||
name: string;
|
||||
target_month: string;
|
||||
area_id?: string;
|
||||
interest_rate_delta_bps: number;
|
||||
policy_signal_delta: number;
|
||||
supply_delta_pct: number;
|
||||
rent_delta_pct: number;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type ScenarioAreaResult = {
|
||||
area_id: string;
|
||||
name: string;
|
||||
district: string;
|
||||
baseline_score: number;
|
||||
projected_score: number;
|
||||
score_delta: number;
|
||||
baseline_price_psm: number;
|
||||
projected_price_psm: number;
|
||||
price_delta_pct: number;
|
||||
projected_rent_yield_pct: number;
|
||||
projected_liquidity_score: number;
|
||||
projected_supply_risk_score: number;
|
||||
recommendation: string;
|
||||
risk_notes: string[];
|
||||
};
|
||||
|
||||
export type ScenarioVariant = {
|
||||
key: string;
|
||||
name: string;
|
||||
assumptions: string[];
|
||||
items: ScenarioAreaResult[];
|
||||
};
|
||||
|
||||
export type ScenarioRunResponse = {
|
||||
run: ScenarioRun;
|
||||
variants: ScenarioVariant[];
|
||||
};
|
||||
|
||||
export type ComparisonMetricValue = {
|
||||
id: string;
|
||||
value: number | null;
|
||||
@@ -663,6 +716,29 @@ export async function fetchAreaScoreLineage(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchScenarioRuns(filters: {
|
||||
target_month?: string;
|
||||
area_id?: string;
|
||||
} = {}): Promise<ScenarioRun[]> {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
const query = params.toString();
|
||||
return fetchJson(`/api/v1/scenario-runs${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function createScenarioRun(
|
||||
payload: CreateScenarioRun,
|
||||
): Promise<ScenarioRunResponse> {
|
||||
return fetchJson("/api/v1/scenario-runs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
|
||||
Reference in New Issue
Block a user