feat: add scenario analysis workspace

This commit is contained in:
2026-06-24 16:43:27 +08:00
parent b559df50d8
commit f0ac47e040
7 changed files with 986 additions and 20 deletions

View File

@@ -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,

View File

@@ -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,