chore: 提交本轮 Rust 架构迁移相关改动

- docker-compose: 下线 Python backend/config-service,切换至 config-service-rs
- archive: 归档 legacy Python 目录至 archive/python/*
- services: 新增/更新 common-contracts、api-gateway、各 provider、report-generator-service、config-service-rs
- data-persistence-service: API/system 模块与模型/DTO 调整
- frontend: 更新 useApi 与 API 路由
- docs: 更新路线图并勾选光荣退役
- cleanup: 移除 data-distance-service 占位测试
This commit is contained in:
Lv, Qi 2025-11-16 20:55:46 +08:00
parent 0e45dd4a3f
commit 5327e76aaa
105 changed files with 34170 additions and 309 deletions

2
.gitignore vendored
View File

@ -17,7 +17,7 @@ services/**/node_modules/
# Build artifacts # Build artifacts
dist/ dist/
build/ build/
ref/
# Binaries # Binaries
portwardenc-amd64 portwardenc-amd64

View File

@ -18,6 +18,13 @@ services:
retries: 10 retries: 10
ports: ports:
- "15432:5432" - "15432:5432"
nats:
image: nats:2.9
ports:
- "4222:4222"
- "8222:8222" # For monitoring
volumes:
- nats_data:/data
data-persistence-service: data-persistence-service:
build: build:
@ -38,30 +45,6 @@ services:
# volumes: # volumes:
# - ./:/workspace # - ./:/workspace
backend:
build:
context: .
dockerfile: backend/Dockerfile
container_name: fundamental-backend
working_dir: /workspace/backend
command: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
environment:
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
# Config service base URL
CONFIG_SERVICE_BASE_URL: http://config-service:7000/api/v1
# Data persistence service base URL
DATA_PERSISTENCE_BASE_URL: http://data-persistence-service:3000/api/v1
volumes:
# 挂载整个项目,确保后端代码中对项目根目录的相对路径(如 config/)仍然有效
- ./:/workspace
ports:
- "18000:8000"
depends_on:
config-service:
condition: service_started
data-persistence-service:
condition: service_started
frontend: frontend:
build: build:
@ -71,8 +54,8 @@ services:
working_dir: /workspace/frontend working_dir: /workspace/frontend
command: npm run dev command: npm run dev
environment: environment:
# 让 Next 的 API 路由代理到后端容器 # 让 Next 的 API 路由代理到新的 api-gateway
NEXT_PUBLIC_BACKEND_URL: http://backend:8000/api NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1
# Prisma 直连数据库(与后端共用同一库) # Prisma 直连数据库(与后端共用同一库)
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental?schema=public DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental?schema=public
NODE_ENV: development NODE_ENV: development
@ -84,26 +67,153 @@ services:
ports: ports:
- "13001:3001" - "13001:3001"
depends_on: depends_on:
- backend
- postgres-db - postgres-db
- config-service - api-gateway
config-service:
api-gateway:
build: build:
context: . context: ./services/api-gateway
dockerfile: services/config-service/Dockerfile dockerfile: Dockerfile
container_name: fundamental-config-service container_name: api-gateway
working_dir: /workspace/services/config-service
command: uvicorn app.main:app --host 0.0.0.0 --port 7000
environment: environment:
PROJECT_ROOT: /workspace SERVER_PORT: 4000
volumes: NATS_ADDR: nats://nats:4222
- ./:/workspace DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
# Note: provider_services needs to contain all provider's internal addresses
PROVIDER_SERVICES: '["http://alphavantage-provider-service:8000", "http://tushare-provider-service:8001", "http://finnhub-provider-service:8002", "http://yfinance-provider-service:8003"]'
ports: ports:
- "17000:7000" - "14000:4000"
depends_on:
- nats
- data-persistence-service
- alphavantage-provider-service
- tushare-provider-service
- finnhub-provider-service
- yfinance-provider-service
networks:
- app-network
alphavantage-provider-service:
build:
context: ./services/alphavantage-provider-service
dockerfile: Dockerfile
container_name: alphavantage-provider-service
environment:
SERVER_PORT: 8000
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
ports:
- "18000:8000"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
tushare-provider-service:
build:
context: ./services/tushare-provider-service
dockerfile: Dockerfile
container_name: tushare-provider-service
environment:
SERVER_PORT: 8001
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
TUSHARE_API_URL: http://api.waditu.com
# Please provide your Tushare token here
TUSHARE_API_TOKEN: "YOUR_TUSHARE_API_TOKEN"
ports:
- "18001:8001"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
finnhub-provider-service:
build:
context: ./services/finnhub-provider-service
dockerfile: Dockerfile
container_name: finnhub-provider-service
environment:
SERVER_PORT: 8002
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
FINNHUB_API_URL: https://finnhub.io/api/v1
# Please provide your Finnhub token in .env file
FINNHUB_API_KEY: ${FINNHUB_API_KEY}
ports:
- "18002:8002"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
yfinance-provider-service:
build:
context: ./services/yfinance-provider-service
dockerfile: Dockerfile
container_name: yfinance-provider-service
environment:
SERVER_PORT: 8003
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
ports:
- "18003:8003"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
report-generator-service:
build:
context: ./services/report-generator-service
dockerfile: Dockerfile
container_name: report-generator-service
environment:
SERVER_PORT: 8004
NATS_ADDR: nats://nats:4222
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
# Please provide your LLM provider details in .env file
LLM_API_URL: ${LLM_API_URL}
LLM_API_KEY: ${LLM_API_KEY}
LLM_MODEL: ${LLM_MODEL:-"default-model"}
ports:
- "18004:8004"
depends_on:
- nats
- data-persistence-service
networks:
- app-network
config-service-rs:
build:
context: ./services/config-service-rs
dockerfile: Dockerfile
container_name: config-service-rs
environment:
SERVER_PORT: 5001
# PROJECT_ROOT is set to /workspace in the Dockerfile
ports:
- "15001:5001"
networks:
- app-network
volumes:
- ./config:/workspace/config:ro
# =================================================================
# Python Services (Legacy - to be replaced)
# =================================================================
volumes: volumes:
pgdata: pgdata:
frontend_node_modules: frontend_node_modules:
nats_data:
networks:
app-network:

View File

@ -0,0 +1,155 @@
# 架构规范系统模块SystemModule设计准则
## 1. 引言
### 1.1. 文档目的
本文档旨在为我们的事件驱动微服务架构定义一套**“主规则” (Master Rules)**。它通过提出一个概念性的 **`SystemModule` Trait**,形式化地规定了任何一个希望融入本系统的独立微服务所必须遵循的**行为契约和接口规范**。
此规范的目标是确保系统中的每一个模块(服务)都是:
- **可观测的 (Observable)**: 外部系统如监控面板、API Gateway可以清晰地了解其健康状况和当前任务。
- **配置驱动的 (Configuration-Driven)**: 模块的行为和连接信息可以通过外部配置进行管理。
- **契约绑定的 (Contract-Bound)**: 模块与系统其他部分的交互(消息、事件)遵循共享的、强类型的契约。
- **生命周期可控的 (Lifecycle-Managed)**: 模块的启动、运行和关闭遵循标准模式。
## 2. 架构核心组件回顾
一个标准的`SystemModule`存活在以下核心组件构成的环境中:
- **Message Bus**: 异步通信的唯一通道。
- **Data Persistence Service**: 持久化数据的唯一入口。
- **Shared Contracts Crate (`common-contracts`)**: 所有数据模型、消息定义的唯一事实源。
- **Configuration Source**: 环境变量或配置服务,为模块提供启动参数。
## 3. `SystemModule` Trait模块的行为契约
我们可以将一个“合格”的微服务需要满足的条件抽象地描述为以下Rust Trait。**注意这并非一个需要真实实现的Rust Trait**,而是一个**设计隐喻**,用于清晰地定义每个独立服务(二进制程序)必须对外暴露的行为。
```rust
/// 设计隐喻:一个合格的系统微服务必须提供的能力
pub trait SystemModule {
// --- 1. 自我描述与配置 ---
/// 返回模块的唯一、人类可读的名称,如 "finnhub-provider-service"
fn module_id(&self) -> &'static str;
/// 声明本模块需要从外部获取的所有配置项
/// 用于自动生成文档、部署脚本和配置校验
fn required_configuration(&self) -> Vec<ConfigSpec>;
// --- 2. 消息契约 ---
/// 声明本模块会订阅(监听)哪些命令 (Commands)
/// 用于生成系统交互图和验证消息路由
fn subscribed_commands(&self) -> Vec<MessageType>;
/// 声明本模块会发布(产生)哪些事件 (Events)
fn published_events(&self) -> Vec<MessageType>;
// --- 3. 可观测性接口 (核心) ---
/// [必须实现为HTTP GET /health]
/// 提供模块当前的健康状态
async fn get_health_status(&self) -> HealthStatus;
/// [必须实现为HTTP GET /tasks]
/// 报告模块当前正在处理的所有任务及其进度
/// 这是实现分布式进度追踪的关键
async fn get_current_tasks(&self) -> Vec<TaskProgress>;
// --- 4. 生命周期 ---
/// 模块的主运行循环
/// 包含连接到Message Bus、处理消息、响应健康检查等逻辑
async fn run(&mut self) -> Result<(), ModuleError>;
}
```
## 4. `SystemModule` Trait 的具象化实现
上述Trait中的每一项都必须在微服务中以具体的技术形式落地。
**每个微服务都必须:**
1. **提供一个 `Dockerfile`** 用于容器化部署。
2. 在启动时,根据`required_configuration()`的定义,**从环境变量或配置服务中读取配置**。缺少必要配置必须启动失败。
3. 在启动后,**连接到 Message Bus**,并严格按照`subscribed_commands()`和`published_events()`的声明进行订阅和发布。
4. **实现一个内置的HTTP服务器** (e.g., using Axum),并暴露**两个强制性的API端点**
- `GET /health`: 返回`HealthStatus`的JSON表示。用于服务发现、负载均衡和健康检查。
- `GET /tasks`: 返回`Vec<TaskProgress>`的JSON表示。用于外部系统查询当前模块正在执行什么任务。
### 4.1. 可观测性接口的数据结构
这些结构体将被定义在`common-contracts`中,供所有模块和监控系统使用。
```rust
// common-contracts/src/observability.rs
#[derive(Serialize, Deserialize, Debug)]
pub enum ServiceStatus {
Ok, // 一切正常
Degraded, // 功能部分受损,但仍在运行
Unhealthy, // 服务异常
}
#[derive(Serialize, Deserialize, Debug)]
pub struct HealthStatus {
pub module_id: String,
pub status: ServiceStatus,
pub version: String,
pub details: HashMap<String, String>, // e.g., "message_bus_connection": "ok"
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TaskProgress {
pub request_id: Uuid, // 关联到最初的用户请求
pub task_name: String, // e.g., "fetch_financials_for_aapl"
pub status: String, // e.g., "in_progress", "retrying", "blocked"
pub progress_percent: u8, // 0-100
pub details: String, // 人类可读的详细状态
pub started_at: DateTime<Utc>,
}
```
## 5. 示范设计 (Example Designs)
### 示例一: `finnhub-provider-service`
这是一个相对简单的、无状态的数据拉取服务。
- **`module_id`**: `"finnhub-provider-service"`
- **`required_configuration`**: `FINNHUB_API_KEY`, `MESSAGE_BUS_URL`, `DATA_PERSISTENCE_URL`
- **`subscribed_commands`**: `FetchCompanyDataCommand`
- **`published_events`**: `CompanyProfilePersistedEvent`, `FinancialsPersistedEvent`
- **`GET /health` 返回**: `{ "module_id": "...", "status": "Ok", ... }`
- **`GET /tasks` 返回**:
- 空闲时: `[]`
- 正在为AAPL拉取数据时: `[{ "request_id": "...", "task_name": "fetch_data_for_aapl", "status": "in_progress", "progress_percent": 50, "details": "Fetching financial statements...", ... }]`
### 示例二: `report-generator-service`
这是一个更复杂的、有状态的业务逻辑服务。
- **`module_id`**: `"report-generator-service"`
- **`required_configuration`**: `GEMINI_API_KEY`, `MESSAGE_BUS_URL`, `DATA_PERSISTENCE_URL`
- **`subscribed_commands`**: `GenerateReportCommand` (一个新的命令由API Gateway发起)
- **`published_events`**: `ReportGenerationStarted`, `ReportSectionCompleted`, `ReportCompletedEvent`, `ReportFailedEvent`
- **`GET /health` 返回**: `{ "module_id": "...", "status": "Ok", ... }`
- **`GET /tasks` 返回**:
- 空闲时: `[]`
- 正在为600519.SH生成报告时:
```json
[
{
"request_id": "abc-123",
"task_name": "generate_report_for_600519.SH",
"status": "in_progress",
"progress_percent": 66,
"details": "Generating bull_case analysis, waiting for AI model response.",
"started_at": "..."
}
]
```
## 6. 结论
`SystemModule`规范为我们的微服务生态系统提供了骨架和纪律。通过强制要求所有模块实现标准的可观测性接口和消息契约,我们可以构建一个真正健壮、透明且易于管理的分布式系统。所有新服务的开发都将以此规范为起点。

View File

@ -0,0 +1,23 @@
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
export async function GET(
_req: Request,
context: { params: Promise<{ symbol: string }> }
) {
if (!BACKEND_BASE) {
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
}
const { symbol } = await context.params;
const target = `${BACKEND_BASE}/companies/${encodeURIComponent(symbol)}/profile`;
const resp = await fetch(target, { headers: { 'Content-Type': 'application/json' } });
const headers = new Headers();
const contentType = resp.headers.get('content-type') || 'application/json; charset=utf-8';
headers.set('content-type', contentType);
const cacheControl = resp.headers.get('cache-control');
if (cacheControl) headers.set('cache-control', cacheControl);
const xAccelBuffering = resp.headers.get('x-accel-buffering');
if (xAccelBuffering) headers.set('x-accel-buffering', xAccelBuffering);
return new Response(resp.body, { status: resp.status, headers });
}

View File

@ -0,0 +1,22 @@
import { NextRequest } from 'next/server';
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
export async function POST(req: NextRequest) {
if (!BACKEND_BASE) {
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
}
const body = await req.text();
const resp = await fetch(`${BACKEND_BASE}/data-requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
const text = await resp.text();
return new Response(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
});
}

View File

@ -0,0 +1,23 @@
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
export async function GET(
_req: Request,
context: { params: Promise<{ request_id: string }> }
) {
if (!BACKEND_BASE) {
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
}
const { request_id } = await context.params;
const target = `${BACKEND_BASE}/tasks/${encodeURIComponent(request_id)}`;
const resp = await fetch(target, { headers: { 'Content-Type': 'application/json' } });
const headers = new Headers();
const contentType = resp.headers.get('content-type') || 'application/json; charset=utf-8';
headers.set('content-type', contentType);
const cacheControl = resp.headers.get('cache-control');
if (cacheControl) headers.set('cache-control', cacheControl);
const xAccelBuffering = resp.headers.get('x-accel-buffering');
if (xAccelBuffering) headers.set('x-accel-buffering', xAccelBuffering);
return new Response(resp.body, { status: resp.status, headers });
}

View File

@ -1,66 +1,97 @@
import useSWR from 'swr'; import useSWR, { SWRConfiguration } from "swr";
import { useConfigStore } from '@/stores/useConfigStore'; import { Financials, FinancialsIdentifier } from "@/types";
import { BatchFinancialDataResponse, FinancialConfigResponse, AnalysisConfigResponse, TodaySnapshotResponse, RealTimeQuoteResponse } from '@/types'; import { useEffect, useState } from "react";
import { AnalysisStep, AnalysisTask } from "@/lib/execution-step-manager";
const fetcher = async (url: string) => { const fetcher = (url: string) => fetch(url).then((res) => res.json());
const res = await fetch(url);
const contentType = res.headers.get('Content-Type') || '';
const text = await res.text();
// 尝试解析JSON // --- 新的异步任务Hooks ---
const tryParseJson = () => {
try { return JSON.parse(text); } catch { return null; } // 用于触发数据获取任务
export function useDataRequest() {
const [isMutating, setIsMutating] = useState(false);
const [error, setError] = useState<Error | null>(null);
const trigger = async (symbol: string, market: string): Promise<string | undefined> => {
setIsMutating(true);
setError(null);
try {
const res = await fetch(`/api/data-requests`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ symbol, market }),
});
if (!res.ok) {
throw new Error(`Request failed with status ${res.status}`);
}
const data = await res.json();
return data.request_id;
} catch (e: any) {
setError(e);
} finally {
setIsMutating(false);
}
}; };
const data = contentType.includes('application/json') ? tryParseJson() : tryParseJson(); return {
trigger,
if (!res.ok) { isMutating,
// 后端可能返回纯文本错误,统一抛出可读错误 error,
const message = data && data.detail ? data.detail : (text || `Request failed: ${res.status}`); };
throw new Error(message);
}
if (data === null) {
throw new Error('无效的服务器响应非JSON');
}
return data;
};
export function useConfig() {
const { setConfig, setError } = useConfigStore();
const { data, error, isLoading } = useSWR('/api/config', fetcher, {
onSuccess: (data) => setConfig(data),
onError: (err) => setError(err.message),
});
return { data, error, isLoading };
} }
export async function updateConfig(newConfig: any) {
const res = await fetch('/api/config', { // 用于轮询任务进度
method: 'PUT', export function useTaskProgress(requestId: string | null, options?: SWRConfiguration) {
headers: { 'Content-Type': 'application/json' }, const { data, error, isLoading } = useSWR(
body: JSON.stringify(newConfig), requestId ? `/api/tasks/${requestId}` : null,
}); fetcher,
if (!res.ok) throw new Error(await res.text()); {
return res.json(); refreshInterval: 2000, // 每2秒轮询一次
...options,
errorRetryCount: 2,
}
);
const isFinished = !isLoading && (data?.status?.includes('completed') || data?.status?.includes('failed') || !data);
return {
progress: data,
isLoading,
isError: error,
isFinished,
};
} }
export async function testConfig(type: string, data: any) { // --- 保留的旧Hooks (用于查询最终数据) ---
const res = await fetch('/api/config/test', {
method: 'POST', export function useCompanyProfile(symbol?: string, market?: string) {
headers: { 'Content-Type': 'application/json' }, const { data, error, isLoading } = useSWR(
body: JSON.stringify({ config_type: type, config_data: data }), symbol && market ? `/api/companies/${symbol}/profile` : null,
}); fetcher
if (!res.ok) throw new Error(await res.text()); );
return res.json();
return {
profile: data,
isLoading,
isError: error,
};
} }
export function useFinancialConfig() { // ... 这里的其他数据查询Hooks (如财务报表等) 也将遵循类似的模式,
return useSWR<FinancialConfigResponse>('/api/financials/config', fetcher); // 直接从api-gateway查询持久化后的数据 ...
}
// --- 废弃的旧Hooks ---
/**
* @deprecated This hook is deprecated and will be removed.
* Use useDataRequest and useTaskProgress instead.
*/
export function useChinaFinancials(ts_code?: string, years: number = 10) { export function useChinaFinancials(ts_code?: string, years: number = 10) {
return useSWR<BatchFinancialDataResponse>( return useSWR<BatchFinancialDataResponse>(
ts_code ? `/api/financials/cn/${encodeURIComponent(ts_code)}?years=${encodeURIComponent(String(years))}` : null, ts_code ? `/api/financials/cn/${encodeURIComponent(ts_code)}?years=${encodeURIComponent(String(years))}` : null,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
[package]
name = "alphavantage-provider-service"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web Service
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5.0", features = ["cors"] }
# Shared Contracts
common-contracts = { path = "../common-contracts" }
# Generic MCP Client
rmcp = { version = "0.8.5", features = ["client", "transport-streamable-http-client-reqwest"] }
# Message Queue (NATS)
async-nats = "0.33"
futures-util = "0.3"
# Data Persistence Client
reqwest = { version = "0.12", features = ["json"] }
# Concurrency & Async
async-trait = "0.1"
dashmap = "5.5.3" # For concurrent task tracking
uuid = { version = "1.8", features = ["v4"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Logging & Telemetry
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Configuration
config = "0.14"
secrecy = { version = "0.8", features = ["serde"] }
# Error Handling
thiserror = "1.0"
anyhow = "1.0"
chrono = { version = "0.4", features = ["serde"] }

View File

@ -0,0 +1,119 @@
use secrecy::{Secret, ExposeSecret};
use serde::Deserialize;
use crate::error::Result;
const DEFAULT_BASE_URL: &str = "https://www.alphavantage.co";
#[derive(Clone)]
pub struct AlphaVantageHttpClient {
client: reqwest::Client,
base_url: String,
api_key: Secret<String>,
}
impl AlphaVantageHttpClient {
pub fn new(api_key: Secret<String>) -> Self {
Self {
client: reqwest::Client::new(),
base_url: DEFAULT_BASE_URL.to_string(),
api_key,
}
}
pub async fn fetch_overview(&self, symbol: &str) -> Result<AvOverview> {
let url = format!("{}/query", self.base_url);
let resp = self
.client
.get(&url)
.query(&[
("function", "OVERVIEW"),
("symbol", symbol),
("apikey", self.api_key.expose_secret()),
])
.send()
.await?
.error_for_status()?
.json::<AvOverview>()
.await?;
Ok(resp)
}
pub async fn fetch_global_quote(&self, symbol: &str) -> Result<AvGlobalQuoteEnvelope> {
let url = format!("{}/query", self.base_url);
let resp = self
.client
.get(&url)
.query(&[
("function", "GLOBAL_QUOTE"),
("symbol", symbol),
("apikey", self.api_key.expose_secret()),
])
.send()
.await?
.error_for_status()?
.json::<AvGlobalQuoteEnvelope>()
.await?;
Ok(resp)
}
}
// --- Alpha Vantage Models (subset, strong-typed) ---
#[derive(Debug, Deserialize)]
pub struct AvOverview {
#[serde(rename = "Symbol")]
pub symbol: String,
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Industry")]
pub industry: String,
#[serde(rename = "Exchange")]
pub exchange: String,
#[serde(rename = "Currency")]
pub currency: String,
#[serde(rename = "Country")]
pub country: String,
#[serde(rename = "Sector")]
pub sector: String,
#[serde(rename = "MarketCapitalization")]
pub market_capitalization: Option<String>,
#[serde(rename = "PERatio")]
pub pe_ratio: Option<String>,
#[serde(rename = "Beta")]
pub beta: Option<String>,
#[serde(rename = "Description")]
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AvGlobalQuoteEnvelope {
#[serde(rename = "Global Quote")]
pub global_quote: AvGlobalQuote,
}
#[derive(Debug, Deserialize)]
pub struct AvGlobalQuote {
#[serde(rename = "01. symbol")]
pub symbol: String,
#[serde(rename = "02. open")]
pub open: Option<String>,
#[serde(rename = "03. high")]
pub high: Option<String>,
#[serde(rename = "04. low")]
pub low: Option<String>,
#[serde(rename = "05. price")]
pub price: Option<String>,
#[serde(rename = "06. volume")]
pub volume: Option<String>,
#[serde(rename = "07. latest trading day")]
pub latest_trading_day: Option<String>,
#[serde(rename = "08. previous close")]
pub previous_close: Option<String>,
#[serde(rename = "09. change")]
pub change: Option<String>,
#[serde(rename = "10. change percent")]
pub change_percent: Option<String>,
}

View File

@ -0,0 +1,43 @@
use std::collections::HashMap;
use axum::{
extract::State,
response::Json,
routing::get,
Router,
};
use common_contracts::observability::{HealthStatus, ServiceStatus, TaskProgress};
use crate::state::AppState;
pub fn create_router(app_state: AppState) -> Router {
Router::new()
.route("/health", get(health_check))
.route("/tasks", get(get_current_tasks))
.with_state(app_state)
}
/// [GET /health]
/// Provides the current health status of the module.
async fn health_check(State(_state): State<AppState>) -> Json<HealthStatus> {
let mut details = HashMap::new();
// In a real scenario, we would check connections to the message bus, etc.
details.insert("message_bus_connection".to_string(), "ok".to_string());
let status = HealthStatus {
module_id: "alphavantage-provider-service".to_string(),
status: ServiceStatus::Ok,
version: env!("CARGO_PKG_VERSION").to_string(),
details,
};
Json(status)
}
/// [GET /tasks]
/// Reports all currently processing tasks and their progress.
async fn get_current_tasks(State(state): State<AppState>) -> Json<Vec<TaskProgress>> {
let tasks: Vec<TaskProgress> = state
.tasks
.iter()
.map(|entry| entry.value().clone())
.collect();
Json(tasks)
}

View File

@ -0,0 +1,60 @@
use crate::error::{AppError, Result};
use rmcp::{ClientHandler, ServiceExt};
use rmcp::model::CallToolRequestParam;
use rmcp::transport::StreamableHttpClientTransport;
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default)]
struct DummyClientHandler;
impl ClientHandler for DummyClientHandler {
fn get_info(&self) -> rmcp::model::ClientInfo {
rmcp::model::ClientInfo::default()
}
}
// 不需要 Clone由外部用 Arc 包裹
pub struct AvClient {
service: rmcp::service::RunningService<rmcp::RoleClient, DummyClientHandler>,
}
impl AvClient {
pub async fn connect(mcp_endpoint_url: &str) -> Result<Self> {
let transport = StreamableHttpClientTransport::from_uri(mcp_endpoint_url.to_string());
let running = DummyClientHandler
::default()
.serve(transport)
.await
.map_err(|e| AppError::Configuration(format!("Fail to init MCP service: {e:?}")))?;
Ok(Self { service: running })
}
pub async fn query(&self, function: &str, params: &[(&str, &str)]) -> Result<Value> {
let mut args = Map::new();
args.insert("function".to_string(), Value::String(function.to_string()));
for (k, v) in params {
args.insert((*k).to_string(), Value::String((*v).to_string()));
}
let result = self
.service
.call_tool(CallToolRequestParam {
name: function.to_string().into(),
arguments: Some(args),
})
.await
.map_err(|e| AppError::Configuration(format!("MCP call_tool error: {e:?}")))?;
if let Some(v) = result.structured_content {
return Ok(v);
}
// fallback: try parse first text content as json
if let Some(text) = result.content.first().and_then(|c| c.raw.as_text()).map(|t| t.text.clone()) {
if let Ok(v) = serde_json::from_str::<Value>(&text) {
return Ok(v);
}
return Ok(Value::String(text));
}
Ok(Value::Null)
}
}

View File

@ -0,0 +1,20 @@
use secrecy::{ExposeSecret, Secret};
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct AppConfig {
pub server_port: u16,
pub nats_addr: String,
pub alphavantage_api_key: Secret<String>,
pub data_persistence_service_url: String,
}
impl AppConfig {
pub fn load() -> Result<Self, config::ConfigError> {
let config = config::Config::builder()
.add_source(config::Environment::default().separator("__"))
.build()?;
config.try_deserialize()
}
}

View File

@ -0,0 +1,40 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Configuration error: {0}")]
Configuration(String),
#[error("Message bus error: {0}")]
MessageBus(#[from] async_nats::Error),
#[error("Message bus publish error: {0}")]
MessageBusPublish(#[from] async_nats::PublishError),
#[error("Message bus subscribe error: {0}")]
MessageBusSubscribe(String),
#[error("Message bus connect error: {0}")]
MessageBusConnect(String),
#[error("HTTP request to another service failed: {0}")]
ServiceRequest(#[from] reqwest::Error),
#[error("Data parsing error: {0}")]
DataParsing(#[from] anyhow::Error),
}
// 手动实现针对 async-nats 泛型错误类型的 From 转换
impl From<async_nats::error::Error<async_nats::ConnectErrorKind>> for AppError {
fn from(err: async_nats::error::Error<async_nats::ConnectErrorKind>) -> Self {
AppError::MessageBusConnect(err.to_string())
}
}
impl From<async_nats::SubscribeError> for AppError {
fn from(err: async_nats::SubscribeError) -> Self {
AppError::MessageBusSubscribe(err.to_string())
}
}

View File

@ -0,0 +1,46 @@
mod api;
mod config;
mod error;
mod mapping;
mod message_consumer;
mod persistence;
mod state;
mod worker;
mod av_client;
use crate::config::AppConfig;
use crate::error::Result;
use crate::state::AppState;
use tracing::info;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
info!("Starting alphavantage-provider-service...");
// Load configuration
let config = AppConfig::load().map_err(|e| error::AppError::Configuration(e.to_string()))?;
let port = config.server_port;
// Initialize application state
let app_state = AppState::new(config)?;
// Create the Axum router
let app = api::create_router(app_state.clone());
// --- Start the message consumer ---
tokio::spawn(message_consumer::run(app_state));
// Start the HTTP server
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap();
info!("HTTP server listening on port {}", port);
axum::serve(listener, app).await.unwrap();
Ok(())
}

View File

@ -0,0 +1,184 @@
//! 数据模型转换模块AlphaVantage 专用映射)
//!
//! 使用 AlphaVantage HTTP JSON → 我们的 DTO 的强类型转换。
//!
use anyhow::{anyhow, Context};
use chrono::{NaiveDate, Utc};
use common_contracts::dtos::{CompanyProfileDto, RealtimeQuoteDto, TimeSeriesFinancialDto};
use serde_json::Value;
// --- Helpers ---
fn parse_date(s: &str) -> anyhow::Result<NaiveDate> {
NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|e| anyhow!("Invalid date '{}': {}", s, e))
}
fn parse_f64_opt(v: &Value) -> Option<f64> {
v.as_str().and_then(|s| s.parse::<f64>().ok())
}
fn required_str<'a>(v: &'a Value, key: &str) -> anyhow::Result<&'a str> {
v.get(key)
.and_then(|x| x.as_str())
.ok_or_else(|| anyhow!("Missing required string field '{}'", key))
}
// --- CompanyProfile Mapping ---
pub fn parse_company_profile(v: Value) -> anyhow::Result<CompanyProfileDto> {
Ok(CompanyProfileDto {
symbol: required_str(&v, "Symbol")?.to_string(),
name: required_str(&v, "Name")?.to_string(),
list_date: v
.get("IPODate")
.and_then(|x| x.as_str())
.map(parse_date)
.transpose()?,
industry: v.get("Industry").and_then(|x| x.as_str()).map(|s| s.to_string()),
additional_info: Some(serde_json::json!({
"exchange": v.get("Exchange"),
"currency": v.get("Currency"),
"country": v.get("Country"),
"sector": v.get("Sector"),
"market_capitalization": v.get("MarketCapitalization"),
"pe_ratio": v.get("PERatio"),
"beta": v.get("Beta")
})),
})
}
// --- Financials Mapping ---
pub struct CombinedFinancials {
pub income: Value,
pub balance_sheet: Value,
pub cash_flow: Value,
}
pub fn parse_financials(cf: CombinedFinancials) -> anyhow::Result<Vec<TimeSeriesFinancialDto>> {
let symbol = required_str(&cf.income, "symbol")?.to_string();
let income_reports = cf
.income
.get("annualReports")
.and_then(|x| x.as_array())
.context("Missing annualReports in income statement")?;
let balance_reports = cf
.balance_sheet
.get("annualReports")
.and_then(|x| x.as_array())
.unwrap_or(&vec![])
.clone();
let cashflow_reports = cf
.cash_flow
.get("annualReports")
.and_then(|x| x.as_array())
.unwrap_or(&vec![])
.clone();
let mut out: Vec<TimeSeriesFinancialDto> = Vec::new();
for inc in income_reports {
let date_str = required_str(inc, "fiscalDateEnding")?;
let period_date = parse_date(date_str)?;
// income metrics
if let Some(v) = parse_f64_opt(&inc["totalRevenue"]) {
out.push(TimeSeriesFinancialDto {
symbol: symbol.clone(),
metric_name: "revenue".to_string(),
period_date,
value: v,
source: Some("alphavantage".to_string()),
});
}
if let Some(v) = parse_f64_opt(&inc["netIncome"]) {
out.push(TimeSeriesFinancialDto {
symbol: symbol.clone(),
metric_name: "net_income".to_string(),
period_date,
value: v,
source: Some("alphavantage".to_string()),
});
}
// match balance and cashflow by fiscalDateEnding
if let Some(bal) = balance_reports
.iter()
.find(|r| r.get("fiscalDateEnding") == Some(&Value::String(date_str.to_string())))
{
if let Some(v) = parse_f64_opt(&bal["totalAssets"]) {
out.push(TimeSeriesFinancialDto {
symbol: symbol.clone(),
metric_name: "total_assets".to_string(),
period_date,
value: v,
source: Some("alphavantage".to_string()),
});
}
if let Some(v) = parse_f64_opt(&bal["totalLiabilities"]) {
out.push(TimeSeriesFinancialDto {
symbol: symbol.clone(),
metric_name: "total_liabilities".to_string(),
period_date,
value: v,
source: Some("alphavantage".to_string()),
});
}
}
if let Some(cf) = cashflow_reports
.iter()
.find(|r| r.get("fiscalDateEnding") == Some(&Value::String(date_str.to_string())))
{
if let Some(v) = parse_f64_opt(&cf["operatingCashflow"]) {
out.push(TimeSeriesFinancialDto {
symbol: symbol.clone(),
metric_name: "operating_cashflow".to_string(),
period_date,
value: v,
source: Some("alphavantage".to_string()),
});
}
}
}
Ok(out)
}
// --- RealtimeQuote Mapping ---
pub fn parse_realtime_quote(v: Value, market: &str) -> anyhow::Result<RealtimeQuoteDto> {
let q = v
.get("Global Quote")
.ok_or_else(|| anyhow!("Missing 'Global Quote' object"))?;
let symbol = required_str(q, "01. symbol")?.to_string();
let price = parse_f64_opt(&q["05. price"]).context("Invalid price")?;
let open_price = parse_f64_opt(&q["02. open"]);
let high_price = parse_f64_opt(&q["03. high"]);
let low_price = parse_f64_opt(&q["04. low"]);
let prev_close = parse_f64_opt(&q["08. previous close"]);
let change = parse_f64_opt(&q["09. change"]);
let change_percent = q
.get("10. change percent")
.and_then(|x| x.as_str())
.and_then(|s| s.trim_end_matches('%').parse::<f64>().ok());
let volume = q
.get("06. volume")
.and_then(|x| x.as_str())
.and_then(|s| s.parse::<i64>().ok());
Ok(RealtimeQuoteDto {
symbol,
market: market.to_string(),
ts: Utc::now(),
price,
open_price,
high_price,
low_price,
prev_close,
change,
change_percent,
volume,
source: Some("alphavantage".to_string()),
})
}

View File

@ -0,0 +1,47 @@
use crate::error::{AppError, Result};
use crate::state::AppState;
use common_contracts::messages::FetchCompanyDataCommand;
use futures_util::StreamExt;
use tracing::{error, info};
const SUBJECT_NAME: &str = "data_fetch_commands";
pub async fn run(state: AppState) -> Result<()> {
info!("Starting NATS message consumer...");
let client = async_nats::connect(&state.config.nats_addr).await?;
info!("Connected to NATS.");
// This is a simple subscriber. For production, consider JetStream for durability.
let mut subscriber = client.subscribe(SUBJECT_NAME.to_string()).await?;
info!(
"Consumer started, waiting for messages on subject '{}'",
SUBJECT_NAME
);
while let Some(message) = subscriber.next().await {
info!("Received NATS message.");
let state_clone = state.clone();
let publisher_clone = client.clone();
tokio::spawn(async move {
match serde_json::from_slice::<FetchCompanyDataCommand>(&message.payload) {
Ok(command) => {
info!("Deserialized command for symbol: {}", command.symbol);
if let Err(e) =
crate::worker::handle_fetch_command(state_clone, command, publisher_clone)
.await
{
error!("Error handling fetch command: {:?}", e);
}
}
Err(e) => {
error!("Failed to deserialize message: {}", e);
}
}
});
}
Ok(())
}

View File

@ -0,0 +1,70 @@
//!
//! 数据持久化客户端
//!
//! 提供一个类型化的接口,用于与 `data-persistence-service` 进行通信。
//!
use crate::error::Result;
use common_contracts::{
dtos::{CompanyProfileDto, RealtimeQuoteDto, TimeSeriesFinancialBatchDto, TimeSeriesFinancialDto},
};
use tracing::info;
#[derive(Clone)]
pub struct PersistenceClient {
client: reqwest::Client,
base_url: String,
}
impl PersistenceClient {
pub fn new(base_url: String) -> Self {
Self {
client: reqwest::Client::new(),
base_url,
}
}
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
let url = format!("{}/companies", self.base_url);
info!("Upserting company profile for {} to {}", profile.symbol, url);
self.client
.put(&url)
.json(&profile)
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn upsert_realtime_quote(&self, quote: RealtimeQuoteDto) -> Result<()> {
let url = format!("{}/market-data/quotes", self.base_url);
info!("Upserting realtime quote for {} to {}", quote.symbol, url);
self.client
.post(&url)
.json(&quote)
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn batch_insert_financials(&self, dtos: Vec<TimeSeriesFinancialDto>) -> Result<()> {
if dtos.is_empty() {
return Ok(());
}
let url = format!("{}/market-data/financials/batch", self.base_url);
let symbol = dtos[0].symbol.clone();
info!("Batch inserting {} financial statements for {} to {}", dtos.len(), symbol, url);
let batch = TimeSeriesFinancialBatchDto { records: dtos };
self.client
.post(&url)
.json(&batch)
.send()
.await?
.error_for_status()?;
Ok(())
}
}

View File

@ -0,0 +1,23 @@
use std::sync::Arc;
use common_contracts::observability::TaskProgress;
use dashmap::DashMap;
use uuid::Uuid;
use crate::config::AppConfig;
use crate::error::Result;
pub type TaskStore = Arc<DashMap<Uuid, TaskProgress>>;
#[derive(Clone)]
pub struct AppState {
pub config: Arc<AppConfig>,
pub tasks: TaskStore,
}
impl AppState {
pub fn new(config: AppConfig) -> Result<Self> {
Ok(Self {
config: Arc::new(config),
tasks: Arc::new(DashMap::new()),
})
}
}

View File

@ -0,0 +1,149 @@
use crate::error::{AppError, Result};
use crate::mapping::{CombinedFinancials, parse_company_profile, parse_financials, parse_realtime_quote};
use crate::persistence::PersistenceClient;
use crate::state::{AppState, TaskStore};
use anyhow::Context;
use chrono::{Utc, Datelike};
use common_contracts::messages::{FetchCompanyDataCommand, FinancialsPersistedEvent};
use common_contracts::observability::TaskProgress;
use secrecy::ExposeSecret;
use std::sync::Arc;
use tracing::{error, info, instrument};
use uuid::Uuid;
use crate::av_client::AvClient;
#[instrument(skip(state, command, publisher), fields(request_id = %command.request_id, symbol = %command.symbol))]
pub async fn handle_fetch_command(
state: AppState,
command: FetchCompanyDataCommand,
publisher: async_nats::Client,
) -> Result<()> {
info!("Handling fetch data command.");
let task = TaskProgress {
request_id: command.request_id,
task_name: format!("fetch_data_for_{}", command.symbol),
status: "in_progress".to_string(),
progress_percent: 0,
details: "Initializing...".to_string(),
started_at: Utc::now(),
};
state.tasks.insert(command.request_id, task);
let api_key = state.config.alphavantage_api_key.expose_secret();
let mcp_endpoint = format!("https://mcp.alphavantage.co/mcp?apikey={}", api_key);
let client = Arc::new(AvClient::connect(&mcp_endpoint).await?);
let persistence_client =
PersistenceClient::new(state.config.data_persistence_service_url.clone());
let symbol = command.symbol.clone();
update_task_progress(
&state.tasks,
command.request_id,
10,
"Fetching from AlphaVantage...",
)
.await;
// --- 1. Fetch all data in parallel ---
let (overview_json, income_json, balance_json, cashflow_json, quote_json) = {
let params_overview = vec![("symbol", symbol.as_str())];
let params_income = vec![("symbol", symbol.as_str())];
let params_balance = vec![("symbol", symbol.as_str())];
let params_cashflow = vec![("symbol", symbol.as_str())];
let params_quote = vec![("symbol", symbol.as_str())];
let overview_task = client.query("OVERVIEW", &params_overview);
let income_task = client.query("INCOME_STATEMENT", &params_income);
let balance_task = client.query("BALANCE_SHEET", &params_balance);
let cashflow_task = client.query("CASH_FLOW", &params_cashflow);
let quote_task = client.query("GLOBAL_QUOTE", &params_quote);
match tokio::try_join!(
overview_task,
income_task,
balance_task,
cashflow_task,
quote_task
) {
Ok(data) => data,
Err(e) => {
let error_msg = format!("Failed to fetch data from AlphaVantage: {}", e);
error!(error_msg);
update_task_progress(&state.tasks, command.request_id, 100, &error_msg).await;
return Err(e);
}
}
};
update_task_progress(
&state.tasks,
command.request_id,
50,
"Data fetched, transforming and persisting...",
)
.await;
// --- 2. Transform and persist data ---
// Profile
let profile_to_persist =
parse_company_profile(overview_json).context("Failed to parse CompanyProfile")?;
persistence_client
.upsert_company_profile(profile_to_persist)
.await?;
// Financials
let combined_financials = CombinedFinancials {
income: income_json,
balance_sheet: balance_json,
cash_flow: cashflow_json,
};
let financials_to_persist =
parse_financials(combined_financials).context("Failed to parse FinancialStatements")?;
let years_updated: Vec<u16> = financials_to_persist
.iter()
.map(|f| f.period_date.year() as u16)
.collect();
persistence_client
.batch_insert_financials(financials_to_persist)
.await?;
// Quote
let quote_to_persist =
parse_realtime_quote(quote_json, &command.market).context("Failed to parse RealtimeQuote")?;
persistence_client
.upsert_realtime_quote(quote_to_persist)
.await?;
update_task_progress(
&state.tasks,
command.request_id,
90,
"Data persisted, publishing events...",
)
.await;
// --- 3. Publish events ---
let event = FinancialsPersistedEvent {
request_id: command.request_id,
symbol: command.symbol,
years_updated,
};
let subject = "financials.persisted".to_string(); // NATS subject
publisher
.publish(subject, serde_json::to_vec(&event).unwrap().into())
.await?;
state.tasks.remove(&command.request_id);
info!("Task completed successfully.");
Ok(())
}
async fn update_task_progress(tasks: &TaskStore, request_id: Uuid, percent: u8, details: &str) {
if let Some(mut task) = tasks.get_mut(&request_id) {
task.progress_percent = percent;
task.details = details.to_string();
info!("Task update: {}% - {}", percent, details);
}
}

4033
services/api-gateway/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
[package]
name = "api-gateway"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web Service
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5.0", features = ["cors"] }
# Shared Contracts
common-contracts = { path = "../common-contracts" }
# Message Queue (NATS)
async-nats = "0.33"
futures-util = "0.3"
# HTTP Client
reqwest = { version = "0.12", features = ["json"] }
# Concurrency & Async
uuid = { version = "1.8", features = ["v4"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Logging & Telemetry
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Configuration
config = "0.14"
# Error Handling
thiserror = "1.0"
anyhow = "1.0"

View File

@ -0,0 +1,147 @@
use crate::error::Result;
use crate::state::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Json},
routing::{get, post},
Router,
};
use common_contracts::messages::FetchCompanyDataCommand;
use common_contracts::observability::{HealthStatus, ServiceStatus, TaskProgress};
use futures_util::future::join_all;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::{info, warn};
use uuid::Uuid;
const DATA_FETCH_QUEUE: &str = "data_fetch_commands";
// --- Request/Response Structs ---
#[derive(Deserialize)]
pub struct DataRequest {
pub symbol: String,
pub market: String,
}
#[derive(Serialize)]
pub struct RequestAcceptedResponse {
pub request_id: Uuid,
}
// --- Router Definition ---
pub fn create_router(app_state: AppState) -> Router {
Router::new()
.route("/health", get(health_check))
.route("/tasks", get(get_current_tasks)) // This is the old, stateless one
.route("/v1/data-requests", post(trigger_data_fetch))
.route("/v1/companies/:symbol/profile", get(get_company_profile))
.route("/v1/tasks/:request_id", get(get_task_progress))
.with_state(app_state)
}
// --- Health & Stateless Tasks ---
async fn health_check(State(state): State<AppState>) -> Json<HealthStatus> {
let mut details = HashMap::new();
// 提供确定性且无副作用的健康详情,避免访问不存在的状态字段
details.insert("message_bus".to_string(), "nats".to_string());
details.insert("nats_addr".to_string(), state.config.nats_addr.clone());
let status = HealthStatus {
module_id: "api-gateway".to_string(),
status: ServiceStatus::Ok,
version: env!("CARGO_PKG_VERSION").to_string(),
details,
};
Json(status)
}
async fn get_current_tasks() -> Json<Vec<TaskProgress>> {
Json(vec![])
}
// --- API Handlers ---
/// [POST /v1/data-requests]
/// Triggers the data fetching process by publishing a command to the message bus.
async fn trigger_data_fetch(
State(state): State<AppState>,
Json(payload): Json<DataRequest>,
) -> Result<impl IntoResponse> {
let request_id = Uuid::new_v4();
let command = FetchCompanyDataCommand {
request_id,
symbol: payload.symbol,
market: payload.market,
};
info!(request_id = %request_id, "Publishing data fetch command");
state
.nats_client
.publish(
DATA_FETCH_QUEUE.to_string(),
serde_json::to_vec(&command).unwrap().into(),
)
.await?;
Ok((
StatusCode::ACCEPTED,
Json(RequestAcceptedResponse { request_id }),
))
}
/// [GET /v1/companies/:symbol/profile]
/// Queries the persisted company profile from the data-persistence-service.
async fn get_company_profile(
State(state): State<AppState>,
Path(symbol): Path<String>,
) -> Result<impl IntoResponse> {
let profile = state.persistence_client.get_company_profile(&symbol).await?;
Ok(Json(profile))
}
/// [GET /v1/tasks/:request_id]
/// Aggregates task progress from all downstream provider services.
async fn get_task_progress(
State(state): State<AppState>,
Path(request_id): Path<Uuid>,
) -> Result<impl IntoResponse> {
let client = reqwest::Client::new();
let fetches = state
.config
.provider_services
.iter()
.map(|service_url| {
let client = client.clone();
let url = format!("{}/tasks", service_url);
async move {
match client.get(&url).send().await {
Ok(resp) => match resp.json::<Vec<TaskProgress>>().await {
Ok(tasks) => Some(tasks),
Err(e) => {
warn!("Failed to decode tasks from {}: {}", url, e);
None
}
},
Err(e) => {
warn!("Failed to fetch tasks from {}: {}", url, e);
None
}
}
}
});
let results = join_all(fetches).await;
let mut merged: Vec<TaskProgress> = Vec::new();
for maybe_tasks in results {
if let Some(tasks) = maybe_tasks {
merged.extend(tasks);
}
}
if let Some(task) = merged.into_iter().find(|t| t.request_id == request_id) {
Ok((StatusCode::OK, Json(task)).into_response())
} else {
Ok((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Task not found"}))).into_response())
}
}

View File

@ -0,0 +1,19 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct AppConfig {
pub server_port: u16,
pub nats_addr: String,
pub data_persistence_service_url: String,
pub provider_services: Vec<String>,
}
impl AppConfig {
pub fn load() -> Result<Self, config::ConfigError> {
let config = config::Config::builder()
.add_source(config::Environment::default().separator("__"))
.build()?;
config.try_deserialize()
}
}

View File

@ -0,0 +1,58 @@
use thiserror::Error;
use axum::{http::StatusCode, response::IntoResponse, Json};
use serde_json::json;
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Configuration error: {0}")]
Configuration(String),
#[error("Message bus error: {0}")]
MessageBus(#[from] async_nats::Error),
#[error("Message bus publish error: {0}")]
MessageBusPublish(#[from] async_nats::PublishError),
#[error("Message bus subscribe error: {0}")]
MessageBusSubscribe(String),
#[error("Message bus connect error: {0}")]
MessageBusConnect(String),
#[error("HTTP request to another service failed: {0}")]
ServiceRequest(#[from] reqwest::Error),
#[error("An unexpected error occurred.")]
Anyhow(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match &self {
AppError::Configuration(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
AppError::MessageBus(err) => (StatusCode::SERVICE_UNAVAILABLE, err.to_string()),
AppError::MessageBusPublish(err) => (StatusCode::SERVICE_UNAVAILABLE, err.to_string()),
AppError::MessageBusSubscribe(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg.clone()),
AppError::MessageBusConnect(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg.clone()),
AppError::ServiceRequest(err) => (StatusCode::BAD_GATEWAY, err.to_string()),
AppError::Anyhow(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
let body = Json(json!({ "error": message }));
(status, body).into_response()
}
}
// 手动实现针对 async-nats 泛型错误类型的 From 转换
impl From<async_nats::error::Error<async_nats::ConnectErrorKind>> for AppError {
fn from(err: async_nats::error::Error<async_nats::ConnectErrorKind>) -> Self {
AppError::MessageBusConnect(err.to_string())
}
}
impl From<async_nats::SubscribeError> for AppError {
fn from(err: async_nats::SubscribeError) -> Self {
AppError::MessageBusSubscribe(err.to_string())
}
}

View File

@ -0,0 +1,39 @@
mod api;
mod config;
mod error;
mod state;
mod persistence;
use crate::config::AppConfig;
use crate::error::Result;
use crate::state::AppState;
use tracing::info;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
info!("Starting api-gateway service...");
// Load configuration
let config = AppConfig::load().map_err(|e| error::AppError::Configuration(e.to_string()))?;
let port = config.server_port;
// Initialize application state
let app_state = AppState::new(config).await?;
// Create the Axum router
let app = api::create_router(app_state);
// Start the HTTP server
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap();
info!("HTTP server listening on port {}", port);
axum::serve(listener, app).await.unwrap();
Ok(())
}

View File

@ -0,0 +1,34 @@
//!
//! 数据持久化服务客户端
//!
use crate::error::Result;
use common_contracts::dtos::CompanyProfileDto;
#[derive(Clone)]
pub struct PersistenceClient {
client: reqwest::Client,
base_url: String,
}
impl PersistenceClient {
pub fn new(base_url: String) -> Self {
Self {
client: reqwest::Client::new(),
base_url,
}
}
pub async fn get_company_profile(&self, symbol: &str) -> Result<CompanyProfileDto> {
let url = format!("{}/companies/{}", self.base_url, symbol);
let profile = self
.client
.get(&url)
.send()
.await?
.error_for_status()?
.json::<CompanyProfileDto>()
.await?;
Ok(profile)
}
}

View File

@ -0,0 +1,27 @@
use crate::config::AppConfig;
use crate::error::Result;
use crate::persistence::PersistenceClient;
use std::sync::Arc;
use async_nats::Client as NatsClient;
#[derive(Clone)]
pub struct AppState {
pub config: Arc<AppConfig>,
pub nats_client: NatsClient,
pub persistence_client: PersistenceClient,
}
impl AppState {
pub async fn new(config: AppConfig) -> Result<Self> {
let nats_client = async_nats::connect(&config.nats_addr).await?;
let persistence_client =
PersistenceClient::new(config.data_persistence_service_url.clone());
Ok(Self {
config: Arc::new(config),
nats_client,
persistence_client,
})
}
}

2678
services/common-contracts/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
[package]
name = "common-contracts"
version = "0.1.0"
edition = "2021"
description = "Shared strongly-typed contracts (models, DTOs, messages, observability) across services."
authors = ["Lv, Qi <lvsoft@gmail.com>"]
[lib]
name = "common_contracts"
path = "src/lib.rs"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["serde", "v4"] }
rust_decimal = { version = "1.36", features = ["serde"] }
utoipa = { version = "5.4", features = ["chrono", "uuid"] }
sqlx = { version = "0.8.6", features = [ "runtime-tokio-rustls", "postgres", "chrono", "uuid", "json", "rust_decimal" ] }
service_kit = { version = "0.1.2" }

View File

@ -0,0 +1,89 @@
use chrono::NaiveDate;
use service_kit::api_dto;
use serde_json::Value as JsonValue;
use uuid::Uuid;
// Companies API DTOs
#[api_dto]
pub struct CompanyProfileDto {
pub symbol: String,
pub name: String,
pub industry: Option<String>,
pub list_date: Option<NaiveDate>,
pub additional_info: Option<JsonValue>,
}
// Market Data API DTOs
#[api_dto]
pub struct TimeSeriesFinancialDto {
pub symbol: String,
pub metric_name: String,
pub period_date: NaiveDate,
pub value: f64,
pub source: Option<String>,
}
#[api_dto]
pub struct DailyMarketDataDto {
pub symbol: String,
pub trade_date: NaiveDate,
pub open_price: Option<f64>,
pub high_price: Option<f64>,
pub low_price: Option<f64>,
pub close_price: Option<f64>,
pub volume: Option<i64>,
pub pe: Option<f64>,
pub pb: Option<f64>,
pub total_mv: Option<f64>,
}
// Batch DTOs
#[api_dto]
pub struct TimeSeriesFinancialBatchDto {
pub records: Vec<TimeSeriesFinancialDto>,
}
#[api_dto]
pub struct DailyMarketDataBatchDto {
pub records: Vec<DailyMarketDataDto>,
}
// Analysis Results API DTOs
#[api_dto]
pub struct NewAnalysisResultDto {
pub symbol: String,
pub module_id: String,
pub model_name: Option<String>,
pub content: String,
pub meta_data: Option<JsonValue>,
}
#[api_dto]
pub struct AnalysisResultDto {
pub id: Uuid,
pub symbol: String,
pub module_id: String,
pub generated_at: chrono::DateTime<chrono::Utc>,
pub model_name: Option<String>,
pub content: String,
pub meta_data: Option<JsonValue>,
}
// Realtime Quotes DTOs
#[api_dto]
pub struct RealtimeQuoteDto {
pub symbol: String,
pub market: String,
pub ts: chrono::DateTime<chrono::Utc>,
pub price: f64,
pub open_price: Option<f64>,
pub high_price: Option<f64>,
pub low_price: Option<f64>,
pub prev_close: Option<f64>,
pub change: Option<f64>,
pub change_percent: Option<f64>,
pub volume: Option<i64>,
pub source: Option<String>,
}

View File

@ -0,0 +1,6 @@
pub mod dtos;
pub mod models;
pub mod observability;
pub mod messages;

View File

@ -0,0 +1,26 @@
use serde::{Serialize, Deserialize};
use uuid::Uuid;
// --- Commands ---
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FetchCompanyDataCommand {
pub request_id: Uuid,
pub symbol: String,
pub market: String,
}
// --- Events ---
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CompanyProfilePersistedEvent {
pub request_id: Uuid,
pub symbol: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FinancialsPersistedEvent {
pub request_id: Uuid,
pub symbol: String,
pub years_updated: Vec<u16>,
}

View File

@ -0,0 +1,89 @@
use chrono::{DateTime, NaiveDate, Utc};
use serde_json::Value as JsonValue;
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, FromRow)]
pub struct CompanyProfile {
pub symbol: String,
pub name: String,
pub industry: Option<String>,
pub list_date: Option<NaiveDate>,
pub additional_info: Option<JsonValue>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, FromRow)]
pub struct TimeSeriesFinancial {
pub symbol: String,
pub metric_name: String,
pub period_date: NaiveDate,
pub value: rust_decimal::Decimal,
pub source: Option<String>,
}
#[derive(Debug, Clone, FromRow)]
pub struct DailyMarketData {
pub symbol: String,
pub trade_date: NaiveDate,
pub open_price: Option<rust_decimal::Decimal>,
pub high_price: Option<rust_decimal::Decimal>,
pub low_price: Option<rust_decimal::Decimal>,
pub close_price: Option<rust_decimal::Decimal>,
pub volume: Option<i64>,
pub pe: Option<rust_decimal::Decimal>,
pub pb: Option<rust_decimal::Decimal>,
pub total_mv: Option<rust_decimal::Decimal>,
}
#[derive(Debug, Clone, FromRow)]
pub struct AnalysisResult {
pub id: Uuid,
pub symbol: String,
pub module_id: String,
pub generated_at: DateTime<Utc>,
pub model_name: Option<String>,
pub content: String,
pub meta_data: Option<JsonValue>,
}
#[derive(Debug, Clone, FromRow)]
pub struct SystemConfig {
pub config_key: String,
pub config_value: JsonValue,
pub description: Option<String>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, FromRow)]
pub struct ExecutionLog {
pub id: i64,
pub report_id: Uuid,
pub step_name: String,
pub status: String,
pub start_time: DateTime<Utc>,
pub end_time: Option<DateTime<Utc>>,
pub duration_ms: Option<i32>,
pub token_usage: Option<JsonValue>,
pub error_message: Option<String>,
pub log_details: Option<JsonValue>,
}
#[derive(Debug, Clone, FromRow)]
pub struct RealtimeQuote {
pub symbol: String,
pub market: String,
pub ts: DateTime<Utc>,
pub price: rust_decimal::Decimal,
pub open_price: Option<rust_decimal::Decimal>,
pub high_price: Option<rust_decimal::Decimal>,
pub low_price: Option<rust_decimal::Decimal>,
pub prev_close: Option<rust_decimal::Decimal>,
pub change: Option<rust_decimal::Decimal>,
pub change_percent: Option<rust_decimal::Decimal>,
pub volume: Option<i64>,
pub source: Option<String>,
pub updated_at: DateTime<Utc>,
}

View File

@ -0,0 +1,31 @@
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Serialize, Deserialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
pub enum ServiceStatus {
Ok,
Degraded,
Unhealthy,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct HealthStatus {
pub module_id: String,
pub status: ServiceStatus,
pub version: String,
pub details: HashMap<String, String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TaskProgress {
pub request_id: Uuid,
pub task_name: String,
pub status: String,
pub progress_percent: u8,
pub details: String,
pub started_at: DateTime<Utc>,
}

1280
services/config-service-rs/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
[package]
name = "config-service-rs"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
config = "0.14"
anyhow = "1.0"
tower-http = { version = "0.5.0", features = ["cors"] }
once_cell = "1.19"
thiserror = "1.0"
hyper = "1"

View File

@ -0,0 +1,33 @@
# 1. Build Stage
FROM rust:1.78 as builder
WORKDIR /usr/src/app
# Pre-build dependencies to leverage Docker layer caching
COPY ./services/config-service-rs/Cargo.toml ./services/config-service-rs/Cargo.lock* ./services/config-service-rs/
RUN mkdir -p ./services/config-service-rs/src && \
echo "fn main() {}" > ./services/config-service-rs/src/main.rs && \
cargo build --release --bin config-service-rs
# Copy the full source code
COPY ./services/config-service-rs /usr/src/app/services/config-service-rs
COPY ./config /usr/src/app/config
# Build the application
RUN cargo build --release --bin config-service-rs
# 2. Runtime Stage
FROM debian:bookworm-slim
WORKDIR /app
# Set timezone
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Copy the built binary and the config directory from the builder stage
COPY --from=builder /usr/src/app/target/release/config-service-rs /usr/local/bin/
COPY --from=builder /usr/src/app/config ./config
# Set the binary as the entrypoint
ENTRYPOINT ["/usr/local/bin/config-service-rs"]

View File

@ -0,0 +1,62 @@
use anyhow::anyhow;
use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Json, Response},
routing::get,
Router,
};
use once_cell::sync::Lazy;
use serde_json::Value;
use std::{path::PathBuf, sync::Arc};
use tracing::error;
use crate::{config::AppConfig, error::AppError};
static CONFIGS: Lazy<Arc<CachedConfig>> = Lazy::new(|| Arc::new(CachedConfig::load_from_disk()));
struct CachedConfig {
system: Value,
analysis: Value,
}
impl CachedConfig {
fn load_from_disk() -> Self {
let config = AppConfig::load().expect("Failed to load app config for caching");
let config_dir = PathBuf::from(&config.project_root).join("config");
let system_path = config_dir.join("config.json");
let analysis_path = config_dir.join("analysis-config.json");
let system_content = std::fs::read_to_string(system_path)
.expect("Failed to read system config.json");
let system: Value = serde_json::from_str(&system_content)
.expect("Failed to parse system config.json");
let analysis_content = std::fs::read_to_string(analysis_path)
.expect("Failed to read analysis-config.json");
let analysis: Value = serde_json::from_str(&analysis_content)
.expect("Failed to parse analysis-config.json");
Self { system, analysis }
}
}
pub fn create_router() -> Router {
Router::new()
.route("/", get(root))
.route("/api/v1/system", get(get_system_config))
.route("/api/v1/analysis-modules", get(get_analysis_modules))
}
async fn root() -> &'static str {
"OK"
}
async fn get_system_config() -> Json<Value> {
Json(CONFIGS.system.clone())
}
async fn get_analysis_modules() -> Json<Value> {
Json(CONFIGS.analysis.clone())
}

View File

@ -0,0 +1,19 @@
use config::{Config, ConfigError, Environment};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct AppConfig {
pub server_port: u16,
pub project_root: String,
}
impl AppConfig {
pub fn load() -> Result<Self, ConfigError> {
let config = Config::builder()
.set_default("server_port", 8080)?
.set_default("project_root", "/workspace")?
.add_source(Environment::default().separator("__"))
.build()?;
config.try_deserialize()
}
}

View File

@ -0,0 +1,18 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Configuration error: {0}")]
Config(#[from] config::ConfigError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parsing error: {0}")]
Json(#[from] serde_json::Error),
#[error("Server startup error: {0}")]
Server(#[from] hyper::Error),
}

View File

@ -0,0 +1,27 @@
mod api;
mod config;
mod error;
use crate::{config::AppConfig, error::Result};
use axum::Router;
use std::net::SocketAddr;
use tracing::info;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let config = AppConfig::load()?;
let port = config.server_port;
let app = api::create_router();
let addr = SocketAddr::from(([0, 0, 0, 0], port));
info!("Server listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}

View File

@ -323,6 +323,20 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "common-contracts"
version = "0.1.0"
dependencies = [
"chrono",
"rust_decimal",
"serde",
"serde_json",
"service_kit",
"sqlx",
"utoipa",
"uuid",
]
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@ -445,6 +459,7 @@ dependencies = [
"axum", "axum",
"axum-embed", "axum-embed",
"chrono", "chrono",
"common-contracts",
"dotenvy", "dotenvy",
"http-body-util", "http-body-util",
"rmcp 0.8.5", "rmcp 0.8.5",

View File

@ -26,6 +26,7 @@ rmcp = { version = "0.8.5", features = [
"transport-streamable-http-server", "transport-streamable-http-server",
"transport-worker" "transport-worker"
] } ] }
common-contracts = { path = "../common-contracts" }
# Web framework # Web framework
axum = "0.8" axum = "0.8"

View File

@ -3,3 +3,4 @@
pub mod companies; pub mod companies;
pub mod market_data; pub mod market_data;
pub mod analysis; pub mod analysis;
pub mod system;

View File

@ -0,0 +1,36 @@
use axum::{extract::State, Json};
use common_contracts::observability::{HealthStatus, ServiceStatus, TaskProgress};
use service_kit::api;
use std::collections::HashMap;
use crate::{AppState, ServerError};
#[api(GET, "/health", output(detail = "HealthStatus"))]
pub async fn get_health(State(state): State<AppState>) -> Result<Json<HealthStatus>, ServerError> {
// Basic DB connectivity check
let db_ok = sqlx::query_scalar::<_, i32>("select 1")
.fetch_one(state.pool())
.await
.map(|v| v == 1)
.unwrap_or(false);
let mut details = HashMap::new();
details.insert("db_connection".to_string(), if db_ok { "ok".into() } else { "error".into() });
let status = if db_ok { ServiceStatus::Ok } else { ServiceStatus::Unhealthy };
let health = HealthStatus {
module_id: "data-persistence-service".to_string(),
status,
version: env!("CARGO_PKG_VERSION").to_string(),
details,
};
Ok(Json(health))
}
#[api(GET, "/tasks", output(list = "TaskProgress"))]
pub async fn get_tasks(_state: State<AppState>) -> Result<Json<Vec<TaskProgress>>, ServerError> {
// data-persistence-service 当前不进行异步任务处理,返回空列表
Ok(Json(Vec::new()))
}

View File

@ -1,99 +1 @@
use chrono::NaiveDate; pub use common_contracts::dtos::*;
use service_kit::api_dto;
use serde_json::Value as JsonValue;
use uuid::Uuid;
// =================================================================================
// Companies API DTOs (Task T3.1)
// =================================================================================
#[api_dto]
pub struct CompanyProfileDto {
pub symbol: String,
pub name: String,
pub industry: Option<String>,
pub list_date: Option<NaiveDate>,
pub additional_info: Option<JsonValue>,
}
// =================================================================================
// Market Data API DTOs (Task T3.2)
// =================================================================================
#[api_dto]
pub struct TimeSeriesFinancialDto {
pub symbol: String,
pub metric_name: String,
pub period_date: NaiveDate,
pub value: f64, // Using f64 for simplicity in DTOs, will be handled as Decimal in db
pub source: Option<String>,
}
#[api_dto]
pub struct DailyMarketDataDto {
pub symbol: String,
pub trade_date: NaiveDate,
pub open_price: Option<f64>,
pub high_price: Option<f64>,
pub low_price: Option<f64>,
pub close_price: Option<f64>,
pub volume: Option<i64>,
pub pe: Option<f64>,
pub pb: Option<f64>,
pub total_mv: Option<f64>,
}
// Batch DTOs to satisfy #[api] macro restriction on Json<Vec<T>> in request bodies
#[api_dto]
pub struct TimeSeriesFinancialBatchDto {
pub records: Vec<TimeSeriesFinancialDto>,
}
#[api_dto]
pub struct DailyMarketDataBatchDto {
pub records: Vec<DailyMarketDataDto>,
}
// =================================================================================
// Analysis Results API DTOs (Task T3.3)
// =================================================================================
#[api_dto]
pub struct NewAnalysisResultDto {
pub symbol: String,
pub module_id: String,
pub model_name: Option<String>,
pub content: String,
pub meta_data: Option<JsonValue>,
}
#[api_dto]
pub struct AnalysisResultDto {
pub id: Uuid,
pub symbol: String,
pub module_id: String,
pub generated_at: chrono::DateTime<chrono::Utc>,
pub model_name: Option<String>,
pub content: String,
pub meta_data: Option<JsonValue>,
}
// =================================================================================
// Realtime Quotes DTOs
// =================================================================================
#[api_dto]
pub struct RealtimeQuoteDto {
pub symbol: String,
pub market: String,
pub ts: chrono::DateTime<chrono::Utc>,
pub price: f64,
pub open_price: Option<f64>,
pub high_price: Option<f64>,
pub low_price: Option<f64>,
pub prev_close: Option<f64>,
pub change: Option<f64>,
pub change_percent: Option<f64>,
pub volume: Option<i64>,
pub source: Option<String>,
}

View File

@ -1,87 +1 @@
use chrono::{DateTime, NaiveDate, Utc}; pub use common_contracts::models::*;
use serde_json::Value as JsonValue;
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, FromRow)]
pub struct CompanyProfile {
pub symbol: String,
pub name: String,
pub industry: Option<String>,
pub list_date: Option<NaiveDate>,
pub additional_info: Option<JsonValue>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, FromRow)]
pub struct TimeSeriesFinancial {
pub symbol: String,
pub metric_name: String,
pub period_date: NaiveDate,
pub value: rust_decimal::Decimal, // Using Decimal for precision with NUMERIC
pub source: Option<String>,
}
#[derive(Debug, Clone, FromRow)]
pub struct DailyMarketData {
pub symbol: String,
pub trade_date: NaiveDate,
pub open_price: Option<rust_decimal::Decimal>,
pub high_price: Option<rust_decimal::Decimal>,
pub low_price: Option<rust_decimal::Decimal>,
pub close_price: Option<rust_decimal::Decimal>,
pub volume: Option<i64>,
pub pe: Option<rust_decimal::Decimal>,
pub pb: Option<rust_decimal::Decimal>,
pub total_mv: Option<rust_decimal::Decimal>,
}
#[derive(Debug, Clone, FromRow)]
pub struct AnalysisResult {
pub id: Uuid,
pub symbol: String,
pub module_id: String,
pub generated_at: DateTime<Utc>,
pub model_name: Option<String>,
pub content: String,
pub meta_data: Option<JsonValue>,
}
#[derive(Debug, Clone, FromRow)]
pub struct SystemConfig {
pub config_key: String,
pub config_value: JsonValue,
pub description: Option<String>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, FromRow)]
pub struct ExecutionLog {
pub id: i64,
pub report_id: Uuid,
pub step_name: String,
pub status: String,
pub start_time: DateTime<Utc>,
pub end_time: Option<DateTime<Utc>>,
pub duration_ms: Option<i32>,
pub token_usage: Option<JsonValue>,
pub error_message: Option<String>,
pub log_details: Option<JsonValue>,
}
#[derive(Debug, Clone, FromRow)]
pub struct RealtimeQuote {
pub symbol: String,
pub market: String,
pub ts: DateTime<Utc>,
pub price: rust_decimal::Decimal,
pub open_price: Option<rust_decimal::Decimal>,
pub high_price: Option<rust_decimal::Decimal>,
pub low_price: Option<rust_decimal::Decimal>,
pub prev_close: Option<rust_decimal::Decimal>,
pub change: Option<rust_decimal::Decimal>,
pub change_percent: Option<rust_decimal::Decimal>,
pub volume: Option<i64>,
pub source: Option<String>,
pub updated_at: DateTime<Utc>,
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
[package]
name = "finnhub-provider-service"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web Service
axum = "0.7"
tokio = { version = "1.0", features = ["full"] }
tower-http = { version = "0.5.0", features = ["cors"] }
# Shared Contracts
common-contracts = { path = "../common-contracts" }
# Generic MCP Client
reqwest = { version = "0.12.24", features = ["json"] }
url = "2.5.2"
chrono = { version = "0.4.38", features = ["serde"] }
rust_decimal = "1.35.0"
rust_decimal_macros = "1.35.0"
itertools = "0.13.0"
# Message Queue (NATS)
async-nats = "0.33"
futures = "0.3"
futures-util = "0.3.31"
# Data Persistence Client
# Concurrency & Async
async-trait = "0.1.80"
dashmap = "5.5"
uuid = { version = "1.6", features = ["v4", "serde"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Logging & Telemetry
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Configuration
config = "0.14"
secrecy = { version = "0.8", features = ["serde"] }
# Error Handling
thiserror = "1.0.61"
anyhow = "1.0"

View File

@ -0,0 +1,31 @@
# 1. Build Stage
FROM rust:1.90 as builder
WORKDIR /usr/src/app
# Pre-build dependencies to leverage Docker layer caching
COPY ./services/common-contracts /usr/src/app/services/common-contracts
COPY ./services/finnhub-provider-service/Cargo.toml ./services/finnhub-provider-service/Cargo.lock* ./services/finnhub-provider-service/
RUN mkdir -p ./services/finnhub-provider-service/src && \
echo "fn main() {}" > ./services/finnhub-provider-service/src/main.rs && \
cargo build --release --bin finnhub-provider-service
# Copy the full source code
COPY ./services/finnhub-provider-service /usr/src/app/services/finnhub-provider-service
# Build the application
RUN cargo build --release --bin finnhub-provider-service
# 2. Runtime Stage
FROM debian:bookworm-slim
# Set timezone
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Copy the built binary from the builder stage
COPY --from=builder /usr/src/app/target/release/finnhub-provider-service /usr/local/bin/
# Set the binary as the entrypoint
ENTRYPOINT ["/usr/local/bin/finnhub-provider-service"]

View File

@ -0,0 +1,43 @@
use std::collections::HashMap;
use axum::{
extract::State,
response::Json,
routing::get,
Router,
};
use common_contracts::observability::{HealthStatus, ServiceStatus, TaskProgress};
use crate::state::AppState;
pub fn create_router(app_state: AppState) -> Router {
Router::new()
.route("/health", get(health_check))
.route("/tasks", get(get_current_tasks))
.with_state(app_state)
}
/// [GET /health]
/// Provides the current health status of the module.
async fn health_check(State(_state): State<AppState>) -> Json<HealthStatus> {
let mut details = HashMap::new();
// In a real scenario, we would check connections to the message bus, etc.
details.insert("message_bus_connection".to_string(), "ok".to_string());
let status = HealthStatus {
module_id: "finnhub-provider-service".to_string(),
status: ServiceStatus::Ok,
version: env!("CARGO_PKG_VERSION").to_string(),
details,
};
Json(status)
}
/// [GET /tasks]
/// Reports all currently processing tasks and their progress.
async fn get_current_tasks(State(state): State<AppState>) -> Json<Vec<TaskProgress>> {
let tasks: Vec<TaskProgress> = state
.tasks
.iter()
.map(|entry| entry.value().clone())
.collect();
Json(tasks)
}

View File

@ -0,0 +1,21 @@
use secrecy::{ExposeSecret, Secret};
use serde::{Deserialize, Deserializer};
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
pub server_port: u16,
pub nats_addr: String,
pub data_persistence_service_url: String,
pub finnhub_api_url: String,
pub finnhub_api_key: Secret<String>,
}
impl AppConfig {
pub fn load() -> Result<Self, config::ConfigError> {
let config = config::Config::builder()
.add_source(config::Environment::default().separator("__"))
.build()?;
config.try_deserialize()
}
}

View File

@ -0,0 +1,40 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Configuration error: {0}")]
Configuration(String),
#[error("Message bus error: {0}")]
MessageBus(#[from] async_nats::Error),
#[error("Message bus publish error: {0}")]
MessageBusPublish(#[from] async_nats::PublishError),
#[error("Message bus subscribe error: {0}")]
MessageBusSubscribe(String),
#[error("Message bus connect error: {0}")]
MessageBusConnect(String),
#[error("HTTP request to another service failed: {0}")]
ServiceRequest(#[from] reqwest::Error),
#[error("Data parsing error: {0}")]
DataParsing(#[from] anyhow::Error),
}
// 手动实现针对 async-nats 泛型错误类型的 From 转换
impl From<async_nats::error::Error<async_nats::ConnectErrorKind>> for AppError {
fn from(err: async_nats::error::Error<async_nats::ConnectErrorKind>) -> Self {
AppError::MessageBusConnect(err.to_string())
}
}
impl From<async_nats::SubscribeError> for AppError {
fn from(err: async_nats::SubscribeError) -> Self {
AppError::MessageBusSubscribe(err.to_string())
}
}

View File

@ -0,0 +1,55 @@
use crate::error::AppError;
use anyhow::anyhow;
use reqwest::Url;
use serde::de::DeserializeOwned;
use tracing::info;
#[derive(Clone)]
pub struct FinnhubClient {
client: reqwest::Client,
base_url: Url,
api_token: String,
}
impl FinnhubClient {
pub fn new(base_url: String, api_token: String) -> Result<Self, AppError> {
let url = Url::parse(&base_url)
.map_err(|e| AppError::Configuration(format!("Invalid base URL: {}", e)))?;
Ok(Self {
client: reqwest::Client::new(),
base_url: url,
api_token,
})
}
pub async fn get<T: DeserializeOwned>(
&self,
endpoint: &str,
params: Vec<(String, String)>,
) -> Result<T, AppError> {
let mut url = self.base_url.join(endpoint).unwrap();
url.query_pairs_mut()
.extend_pairs(params.iter().map(|(k, v)| (k.as_str(), v.as_str())));
url.query_pairs_mut()
.append_pair("token", &self.api_token);
info!("Sending Finnhub request to: {}", url);
let res = self.client.get(url).send().await?;
let status = res.status();
if !status.is_success() {
let error_text = res.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(AppError::DataParsing(anyhow!(format!(
"API request failed with status {}: {}",
status,
error_text
))));
}
let json_res = res.json::<T>().await?;
Ok(json_res)
}
}

View File

@ -0,0 +1,104 @@
use serde::Deserialize;
use crate::{
error::AppError,
fh_client::FinnhubClient,
mapping::{map_financial_dtos, map_profile_dto},
};
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
use tokio;
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FinnhubProfile {
pub country: Option<String>,
pub currency: Option<String>,
pub exchange: Option<String>,
pub name: Option<String>,
pub ticker: Option<String>,
pub ipo: Option<String>,
pub market_capitalization: Option<f64>,
pub share_outstanding: Option<f64>,
pub logo: Option<String>,
pub phone: Option<String>,
pub weburl: Option<String>,
pub finnhub_industry: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FinnhubFinancialsReported {
pub data: Vec<AnnualReport>,
pub symbol: String,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AnnualReport {
pub year: u16,
pub start_date: String,
pub end_date: String,
pub report: Report,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Report {
pub bs: Vec<ReportItem>,
pub ic: Vec<ReportItem>,
pub cf: Vec<ReportItem>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ReportItem {
pub value: f64,
pub concept: String,
pub label: String,
}
pub struct FinnhubDataProvider {
client: FinnhubClient,
}
impl FinnhubDataProvider {
pub fn new(api_url: String, api_token: String) -> Self {
Self {
client: FinnhubClient::new(api_url, api_token).expect("Failed to create Finnhub client"),
}
}
pub async fn fetch_all_data(
&self,
symbol: &str,
) -> Result<(CompanyProfileDto, Vec<TimeSeriesFinancialDto>), AppError> {
let (profile_raw, financials_raw) = self.fetch_raw_data(symbol).await?;
// 1. Build CompanyProfileDto
let profile = map_profile_dto(&profile_raw, symbol)?;
// 2. Build TimeSeriesFinancialDto list
let financials = map_financial_dtos(&financials_raw, symbol)?;
Ok((profile, financials))
}
async fn fetch_raw_data(
&self,
symbol: &str,
) -> Result<(FinnhubProfile, FinnhubFinancialsReported), AppError> {
let params_profile = vec![("symbol".to_string(), symbol.to_string())];
let params_financials = vec![
("symbol".to_string(), symbol.to_string()),
("freq".to_string(), "annual".to_string()),
];
let profile_task = self.client.get::<FinnhubProfile>("/stock/profile2", params_profile);
let financials_task =
self.client
.get::<FinnhubFinancialsReported>("/stock/financials-reported", params_financials);
let (profile_res, financials_res) = tokio::try_join!(profile_task, financials_task)?;
Ok((profile_res, financials_res))
}
}

View File

@ -0,0 +1,47 @@
mod api;
mod config;
mod error;
mod fh_client;
mod finnhub;
mod mapping;
mod message_consumer;
mod persistence;
mod state;
mod worker;
use crate::config::AppConfig;
use crate::error::Result;
use crate::state::AppState;
use tracing::info;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
info!("Starting finnhub-provider-service...");
// Load configuration
let config = AppConfig::load().map_err(|e| error::AppError::Configuration(e.to_string()))?;
let port = config.server_port;
// Initialize application state
let app_state = AppState::new(config);
// Create the Axum router
let app = api::create_router(app_state.clone());
// --- Start the message consumer ---
tokio::spawn(message_consumer::run(app_state));
// Start the HTTP server
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap();
info!("HTTP server listening on port {}", port);
axum::serve(listener, app).await.unwrap();
Ok(())
}

View File

@ -0,0 +1,111 @@
use crate::error::AppError;
use crate::finnhub::{FinnhubFinancialsReported, FinnhubProfile, ReportItem};
use anyhow::anyhow;
use chrono::NaiveDate;
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
use std::collections::HashMap;
pub fn map_profile_dto(profile_raw: &FinnhubProfile, symbol: &str) -> Result<CompanyProfileDto, AppError> {
let name = profile_raw
.name
.clone()
.ok_or_else(|| AppError::DataParsing(anyhow!("Profile name missing")))?;
let industry = profile_raw.finnhub_industry.clone();
let list_date = profile_raw
.ipo
.as_ref()
.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
Ok(CompanyProfileDto {
symbol: symbol.to_string(),
name,
industry,
list_date,
additional_info: None,
})
}
pub fn map_financial_dtos(
financials_raw: &FinnhubFinancialsReported,
symbol: &str,
) -> Result<Vec<TimeSeriesFinancialDto>, AppError> {
let mut out: Vec<TimeSeriesFinancialDto> = Vec::new();
for annual in &financials_raw.data {
let period_date = NaiveDate::parse_from_str(&annual.end_date, "%Y-%m-%d")
.map_err(|e| AppError::DataParsing(anyhow!("Invalid end_date: {}", e)))?;
let bs = &annual.report.bs;
let ic = &annual.report.ic;
let cf = &annual.report.cf;
let mut push_metric = |name: &str, value_opt: Option<f64>| {
if let Some(v) = value_opt {
out.push(TimeSeriesFinancialDto {
symbol: symbol.to_string(),
metric_name: name.to_string(),
period_date,
value: v,
source: Some("finnhub".to_string()),
});
}
};
let revenue = pick_value(ic, &["Revenues"], &["Total revenue", "Revenue"]);
let net_income = pick_value(ic, &["NetIncomeLoss"], &["Net income"]);
let total_assets = pick_value(bs, &["Assets"], &["Total assets"]);
let total_equity = pick_value(
bs,
&[
"StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest",
"StockholdersEquity",
],
&["Total equity"],
);
let goodwill = pick_value(bs, &["Goodwill"], &["Goodwill"]);
let ocf = pick_value(
cf,
&["NetCashProvidedByUsedInOperatingActivities"],
&["Net cash provided by operating activities"],
);
let capex = pick_value(
cf,
&["CapitalExpenditures", "PaymentsToAcquirePropertyPlantAndEquipment"],
&["Capital expenditures"],
);
push_metric("revenue", revenue);
push_metric("net_income", net_income);
push_metric("total_assets", total_assets);
push_metric("total_equity", total_equity);
push_metric("goodwill", goodwill);
push_metric("operating_cash_flow", ocf);
push_metric("capital_expenditure", capex);
if let (Some(ocf_v), Some(capex_v)) = (ocf, capex) {
push_metric("__free_cash_flow", Some(ocf_v - capex_v));
}
}
Ok(out)
}
fn pick_value(
report_block: &[ReportItem],
concept_candidates: &[&str],
label_candidates: &[&str],
) -> Option<f64> {
let normalize = |s: &str| s.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_lowercase();
let by_concept: HashMap<_, _> = report_block.iter().map(|item| (normalize(&item.concept), item.value)).collect();
let by_label: HashMap<_, _> = report_block.iter().map(|item| (normalize(&item.label), item.value)).collect();
for key in concept_candidates {
if let Some(&value) = by_concept.get(&normalize(key)) {
return Some(value);
}
}
for key in label_candidates {
if let Some(&value) = by_label.get(&normalize(key)) {
return Some(value);
}
}
None
}

View File

@ -0,0 +1,95 @@
use crate::error::Result;
use crate::state::AppState;
use common_contracts::messages::FetchCompanyDataCommand;
use futures_util::StreamExt;
use std::sync::Arc;
use tracing::{error, info, warn};
const SUBJECT_NAME: &str = "data_fetch_commands";
pub async fn run(state: AppState) -> Result<()> {
info!("Starting NATS message consumer...");
let client = async_nats::connect(&state.config.nats_addr).await?;
info!("Connected to NATS.");
// This is a simple subscriber. For production, consider JetStream for durability.
let mut subscriber = client.subscribe(SUBJECT_NAME.to_string()).await?;
info!(
"Consumer started, waiting for messages on subject '{}'",
SUBJECT_NAME
);
while let Some(message) = subscriber.next().await {
info!("Received NATS message.");
let state_clone = state.clone();
let publisher_clone = client.clone();
tokio::spawn(async move {
match serde_json::from_slice::<FetchCompanyDataCommand>(&message.payload) {
Ok(command) => {
info!("Deserialized command for symbol: {}", command.symbol);
if let Err(e) =
crate::worker::handle_fetch_command(state_clone, command, publisher_clone)
.await
{
error!("Error handling fetch command: {:?}", e);
}
}
Err(e) => {
error!("Failed to deserialize message: {}", e);
}
}
});
}
Ok(())
}
pub async fn subscribe_to_data_commands(app_state: Arc<AppState>, nats_client: async_nats::Client) -> Result<()> {
let mut subscriber = nats_client.subscribe("data_fetch_commands".to_string()).await?;
while let Some(message) = subscriber.next().await {
let command: FetchCompanyDataCommand = match serde_json::from_slice(&message.payload) {
Ok(c) => c,
Err(e) => {
error!("Failed to deserialize message: {}", e);
continue;
}
};
let task_id = command.request_id;
if command.market.to_uppercase() == "CN" {
info!(
"Skipping command for symbol '{}' as its market ('{}') is 'CN'.",
command.symbol, command.market
);
continue;
}
app_state.tasks.insert(task_id, common_contracts::observability::TaskProgress {
request_id: task_id,
task_name: format!("finnhub:{}", command.symbol),
status: "Received".to_string(),
progress_percent: 0,
details: "Command received".to_string(),
started_at: chrono::Utc::now(),
});
// Spawn the workflow in a separate task
let workflow_state = app_state.clone();
let publisher_clone = nats_client.clone();
tokio::spawn(async move {
let state_owned = (*workflow_state).clone();
let result = crate::worker::handle_fetch_command(state_owned, command, publisher_clone).await;
if let Err(e) = result {
error!(
"Error executing Finnhub workflow for task {}: {:?}",
task_id, e
);
}
});
}
Ok(())
}

View File

@ -0,0 +1,70 @@
//!
//! 数据持久化客户端
//!
//! 提供一个类型化的接口,用于与 `data-persistence-service` 进行通信。
//!
use crate::error::Result;
use common_contracts::{
dtos::{CompanyProfileDto, RealtimeQuoteDto, TimeSeriesFinancialBatchDto, TimeSeriesFinancialDto},
};
use tracing::info;
#[derive(Clone)]
pub struct PersistenceClient {
client: reqwest::Client,
base_url: String,
}
impl PersistenceClient {
pub fn new(base_url: String) -> Self {
Self {
client: reqwest::Client::new(),
base_url,
}
}
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
let url = format!("{}/companies", self.base_url);
info!("Upserting company profile for {} to {}", profile.symbol, url);
self.client
.put(&url)
.json(&profile)
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn upsert_realtime_quote(&self, quote: RealtimeQuoteDto) -> Result<()> {
let url = format!("{}/market-data/quotes", self.base_url);
info!("Upserting realtime quote for {} to {}", quote.symbol, url);
self.client
.post(&url)
.json(&quote)
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn batch_insert_financials(&self, dtos: Vec<TimeSeriesFinancialDto>) -> Result<()> {
if dtos.is_empty() {
return Ok(());
}
let url = format!("{}/market-data/financials/batch", self.base_url);
let symbol = dtos[0].symbol.clone();
info!("Batch inserting {} financial statements for {} to {}", dtos.len(), symbol, url);
let batch = TimeSeriesFinancialBatchDto { records: dtos };
self.client
.post(&url)
.json(&batch)
.send()
.await?
.error_for_status()?;
Ok(())
}
}

View File

@ -0,0 +1,32 @@
use std::sync::Arc;
use dashmap::DashMap;
use uuid::Uuid;
use common_contracts::observability::TaskProgress;
use secrecy::ExposeSecret;
use crate::config::AppConfig;
use crate::finnhub::FinnhubDataProvider;
#[derive(Clone)]
pub struct AppState {
pub tasks: Arc<DashMap<Uuid, TaskProgress>>,
pub config: Arc<AppConfig>,
pub finnhub_provider: Arc<FinnhubDataProvider>,
}
impl AppState {
pub fn new(config: AppConfig) -> Self {
let provider = Arc::new(FinnhubDataProvider::new(
config.finnhub_api_url.clone(),
config.finnhub_api_key.expose_secret().clone(),
));
Self {
tasks: Arc::new(DashMap::new()),
config: Arc::new(config),
finnhub_provider: provider,
}
}
}

View File

@ -0,0 +1,83 @@
use crate::error::Result;
use crate::persistence::PersistenceClient;
use crate::state::AppState;
use chrono::Datelike;
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
use common_contracts::messages::{CompanyProfilePersistedEvent, FetchCompanyDataCommand, FinancialsPersistedEvent};
use common_contracts::observability::TaskProgress;
use std::sync::Arc;
use tracing::info;
pub async fn handle_fetch_command(
state: AppState,
command: FetchCompanyDataCommand,
publisher: async_nats::Client,
) -> Result<()> {
info!("Handling Finnhub fetch data command.");
state.tasks.insert(
command.request_id,
TaskProgress {
request_id: command.request_id,
task_name: format!("finnhub:{}", command.symbol),
status: "FetchingData".to_string(),
progress_percent: 10,
details: "Fetching data from Finnhub".to_string(),
started_at: chrono::Utc::now(),
},
);
// 1. Fetch data via provider
let (profile, financials): (CompanyProfileDto, Vec<TimeSeriesFinancialDto>) = state
.finnhub_provider
.fetch_all_data(&command.symbol)
.await?;
// 2. Persist
{
if let Some(mut task) = state.tasks.get_mut(&command.request_id) {
task.status = "PersistingData".to_string();
task.progress_percent = 60;
task.details = "Persisting data to database".to_string();
}
}
let persistence_client = PersistenceClient::new(state.config.data_persistence_service_url.clone());
persistence_client.upsert_company_profile(profile).await?;
let years_set: std::collections::BTreeSet<u16> =
financials.iter().map(|f| f.period_date.year() as u16).collect();
persistence_client.batch_insert_financials(financials).await?;
// 3. Publish events
let profile_event = CompanyProfilePersistedEvent {
request_id: command.request_id,
symbol: command.symbol.clone(),
};
publisher
.publish(
"events.data.company_profile_persisted".to_string(),
serde_json::to_vec(&profile_event).unwrap().into(),
)
.await?;
let financials_event = FinancialsPersistedEvent {
request_id: command.request_id,
symbol: command.symbol.clone(),
years_updated: years_set.into_iter().collect(),
};
publisher
.publish(
"events.data.financials_persisted".to_string(),
serde_json::to_vec(&financials_event).unwrap().into(),
)
.await?;
// 4. Finalize
if let Some(mut task) = state.tasks.get_mut(&command.request_id) {
task.status = "Completed".to_string();
task.progress_percent = 100;
task.details = "Workflow finished successfully".to_string();
}
info!("Task {} completed successfully.", command.request_id);
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
[package]
name = "report-generator-service"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web Service
axum = "0.7"
tokio = { version = "1.0", features = ["full"] }
tower-http = { version = "0.5.0", features = ["cors"] }
# Shared Contracts
common-contracts = { path = "../common-contracts" }
# Message Queue (NATS)
async-nats = "0.33"
futures = "0.3"
# Data Persistence Client
reqwest = { version = "0.12.4", default-features = false, features = ["json", "rustls-tls"] }
# Concurrency & Async
async-trait = "0.1.80"
dashmap = "5.5" # For concurrent task tracking
uuid = { version = "1.6", features = ["v4", "serde"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Logging & Telemetry
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Configuration
config = "0.14"
secrecy = { version = "0.8", features = ["serde"] }
# Error Handling
thiserror = "1.0.61"
anyhow = "1.0"
chrono = "0.4.38"
tera = "1.19"

View File

@ -0,0 +1,18 @@
COPY ./services/report-generator-service/Cargo.toml ./services/report-generator-service/Cargo.lock* ./services/report-generator-service/
RUN mkdir -p ./services/report-generator-service/src && \
echo "fn main() {}" > ./services/report-generator-service/src/main.rs && \
cargo build --release --bin report-generator-service
# Copy the full source code
COPY ./services/report-generator-service /usr/src/app/services/report-generator-service
# Build the application
RUN cargo build --release --bin report-generator-service
# ... (rest of the file)
# Copy the built binary from the builder stage
COPY --from=builder /usr/src/app/target/release/report-generator-service /usr/local/bin/
# Set the binary as the entrypoint
ENTRYPOINT ["/usr/local/bin/report-generator-service"]

View File

@ -0,0 +1,43 @@
use std::collections::HashMap;
use axum::{
extract::State,
response::Json,
routing::get,
Router,
};
use common_contracts::observability::{HealthStatus, ServiceStatus, TaskProgress};
use crate::state::AppState;
pub fn create_router(app_state: AppState) -> Router {
Router::new()
.route("/health", get(health_check))
.route("/tasks", get(get_current_tasks))
.with_state(app_state)
}
/// [GET /health]
/// Provides the current health status of the module.
async fn health_check(State(_state): State<AppState>) -> Json<HealthStatus> {
let mut details = HashMap::new();
// In a real scenario, we would check connections to the message bus, etc.
details.insert("message_bus_connection".to_string(), "ok".to_string());
let status = HealthStatus {
module_id: "report-generator-service".to_string(),
status: ServiceStatus::Ok,
version: env!("CARGO_PKG_VERSION").to_string(),
details,
};
Json(status)
}
/// [GET /tasks]
/// Reports all currently processing tasks and their progress.
async fn get_current_tasks(State(state): State<AppState>) -> Json<Vec<TaskProgress>> {
let tasks: Vec<TaskProgress> = state
.tasks
.iter()
.map(|entry| entry.value().clone())
.collect();
Json(tasks)
}

View File

@ -0,0 +1,22 @@
use secrecy::{ExposeSecret, Secret};
use serde::{Deserialize, Deserializer};
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
pub server_port: u16,
pub nats_addr: String,
pub data_persistence_service_url: String,
pub llm_api_url: String,
pub llm_api_key: Secret<String>,
pub llm_model: String,
}
impl AppConfig {
pub fn load() -> Result<Self, config::ConfigError> {
let config = config::Config::builder()
.add_source(config::Environment::default().separator("__"))
.build()?;
config.try_deserialize()
}
}

View File

@ -0,0 +1,24 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, ProviderError>;
#[derive(Error, Debug)]
pub enum ProviderError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("LLM API request failed: {0}")]
LlmApi(String),
#[error("Failed to parse JSON response: {0}")]
JsonParsing(#[from] serde_json::Error),
#[error("Configuration error: {0}")]
Configuration(String),
#[error("Persistence client error: {0}")]
Persistence(String),
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
}

View File

@ -0,0 +1,74 @@
use crate::error::ProviderError;
use reqwest::Client;
use secrecy::{ExposeSecret, Secret};
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Clone)]
pub struct LlmClient {
client: Client,
api_url: String,
api_key: Secret<String>,
model: String,
}
#[derive(Serialize)]
struct LlmRequest {
model: String,
prompt: String,
}
#[derive(Deserialize)]
struct LlmResponse {
// This structure depends on the specific LLM provider's API.
// Assuming a simple text completion response for now.
text: String,
}
impl LlmClient {
pub fn new(api_url: String, api_key: Secret<String>, model: String) -> Self {
Self {
client: Client::new(),
api_url,
api_key,
model,
}
}
pub async fn generate_text(&self, prompt: String) -> Result<String, ProviderError> {
let request_payload = LlmRequest {
model: self.model.clone(),
prompt,
};
let res = self
.client
.post(&self.api_url)
.bearer_auth(self.api_key.expose_secret())
.json(&request_payload)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let error_text = res
.text()
.await
.unwrap_or_else(|_| "Unknown LLM API error".to_string());
return Err(ProviderError::LlmApi(format!(
"LLM API request failed with status {}: {}",
status,
error_text
)));
}
// This part needs to be adapted to the actual LLM provider's response format
let response_data: serde_json::Value = res.json().await?;
let text = response_data["choices"][0]["text"]
.as_str()
.unwrap_or("")
.to_string();
Ok(text)
}
}

View File

@ -0,0 +1,54 @@
mod api;
mod config;
mod error;
mod llm_client;
mod message_consumer;
mod persistence;
mod state;
mod templates;
mod worker;
use crate::config::AppConfig;
use crate::error::{ProviderError, Result};
use crate::state::AppState;
use tracing::info;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
info!("Starting report-generator-service...");
// Load configuration
let config = AppConfig::load().map_err(|e| ProviderError::Configuration(e.to_string()))?;
let port = config.server_port;
// Initialize application state
let app_state = AppState::new(config);
// Create the Axum router
let app = api::create_router(app_state.clone());
// --- Start the message consumer ---
let nats_client = async_nats::connect(app_state.config.nats_addr.clone())
.await
.map_err(|e| ProviderError::Internal(anyhow::anyhow!(e.to_string())))?;
let state_clone = app_state.clone();
tokio::spawn(async move {
if let Err(e) = message_consumer::subscribe_to_events(state_clone, nats_client).await {
tracing::error!("message consumer exited with error: {:?}", e);
}
});
// Start the HTTP server
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap();
info!("HTTP server listening on port {}", port);
axum::serve(listener, app).await.unwrap();
Ok(())
}

View File

@ -0,0 +1,40 @@
use std::sync::Arc;
use common_contracts::messages::FinancialsPersistedEvent;
use futures::StreamExt;
use tracing::{error, info};
use crate::{state::AppState, worker::run_report_generation_workflow};
const SUBJECT_NAME: &str = "events.data.financials_persisted";
pub async fn subscribe_to_events(
app_state: AppState,
nats_client: async_nats::Client,
) -> Result<(), anyhow::Error> {
let mut subscriber = nats_client.subscribe(SUBJECT_NAME.to_string()).await?;
info!(
"Consumer started, waiting for messages on subject '{}'",
SUBJECT_NAME
);
while let Some(message) = subscriber.next().await {
info!("Received NATS message for financials persisted event.");
let state_clone = app_state.clone();
tokio::spawn(async move {
match serde_json::from_slice::<FinancialsPersistedEvent>(&message.payload) {
Ok(event) => {
info!("Deserialized event for symbol: {}", event.symbol);
if let Err(e) = run_report_generation_workflow(Arc::new(state_clone), event).await {
error!("Error running report generation workflow: {:?}", e);
}
}
Err(e) => {
error!("Failed to deserialize message: {}", e);
}
}
});
}
Ok(())
}

View File

@ -0,0 +1,101 @@
//!
//! 数据持久化客户端
//!
//! 提供一个类型化的接口,用于与 `data-persistence-service` 进行通信。
//!
use crate::error::Result;
use common_contracts::{
dtos::{CompanyProfileDto, RealtimeQuoteDto, TimeSeriesFinancialBatchDto, TimeSeriesFinancialDto},
};
use tracing::info;
#[derive(Clone)]
pub struct PersistenceClient {
client: reqwest::Client,
base_url: String,
}
impl PersistenceClient {
pub fn new(base_url: String) -> Self {
Self {
client: reqwest::Client::new(),
base_url,
}
}
pub async fn get_company_profile(&self, symbol: &str) -> Result<CompanyProfileDto> {
let url = format!("{}/companies/{}", self.base_url, symbol);
info!("Fetching company profile for {} from {}", symbol, url);
let dto = self
.client
.get(&url)
.send()
.await?
.error_for_status()?
.json::<CompanyProfileDto>()
.await?;
Ok(dto)
}
pub async fn get_financial_statements(
&self,
symbol: &str,
) -> Result<Vec<TimeSeriesFinancialDto>> {
let url = format!("{}/market-data/financials/{}", self.base_url, symbol);
info!("Fetching financials for {} from {}", symbol, url);
let dtos = self
.client
.get(&url)
.send()
.await?
.error_for_status()?
.json::<Vec<TimeSeriesFinancialDto>>()
.await?;
Ok(dtos)
}
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
let url = format!("{}/companies", self.base_url);
info!("Upserting company profile for {} to {}", profile.symbol, url);
self.client
.put(&url)
.json(&profile)
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn upsert_realtime_quote(&self, quote: RealtimeQuoteDto) -> Result<()> {
let url = format!("{}/market-data/quotes", self.base_url);
info!("Upserting realtime quote for {} to {}", quote.symbol, url);
self.client
.post(&url)
.json(&quote)
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn batch_insert_financials(&self, dtos: Vec<TimeSeriesFinancialDto>) -> Result<()> {
if dtos.is_empty() {
return Ok(());
}
let url = format!("{}/market-data/financials/batch", self.base_url);
let symbol = dtos[0].symbol.clone();
info!("Batch inserting {} financial statements for {} to {}", dtos.len(), symbol, url);
let batch = TimeSeriesFinancialBatchDto { records: dtos };
self.client
.post(&url)
.json(&batch)
.send()
.await?
.error_for_status()?;
Ok(())
}
}

View File

@ -0,0 +1,34 @@
use std::sync::Arc;
use dashmap::DashMap;
use tera::Tera;
use uuid::Uuid;
use common_contracts::observability::TaskProgress;
use crate::{config::AppConfig, llm_client::LlmClient, templates::load_tera};
#[derive(Clone)]
pub struct AppState {
pub tasks: Arc<DashMap<Uuid, TaskProgress>>,
pub config: Arc<AppConfig>,
pub llm_client: Arc<LlmClient>,
pub tera: Arc<Tera>,
}
impl AppState {
pub fn new(config: AppConfig) -> Self {
let llm_client = Arc::new(LlmClient::new(
config.llm_api_url.clone(),
config.llm_api_key.clone(),
config.llm_model.clone(),
));
Self {
tasks: Arc::new(DashMap::new()),
config: Arc::new(config),
llm_client,
tera: Arc::new(load_tera()),
}
}
}

View File

@ -0,0 +1,19 @@
use tera::{Context, Tera};
pub fn load_tera() -> Tera {
let mut tera = Tera::default();
tera.add_raw_template(
"company_profile_summary",
include_str!("../templates/company_profile_summary.txt"),
)
.expect("Failed to load company profile summary template");
tera
}
pub fn render_prompt(
tera: &Tera,
template_name: &str,
context: &Context,
) -> Result<String, tera::Error> {
tera.render(template_name, context)
}

View File

@ -0,0 +1,70 @@
use std::sync::Arc;
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
use common_contracts::messages::FinancialsPersistedEvent;
use tera::Context;
use tracing::{info, warn};
use crate::error::{ProviderError, Result};
use crate::persistence::PersistenceClient;
use crate::state::AppState;
use crate::templates::render_prompt;
pub async fn run_report_generation_workflow(
state: Arc<AppState>,
event: FinancialsPersistedEvent,
) -> Result<()> {
info!(
"Starting report generation workflow for symbol: {}",
event.symbol
);
let persistence_client =
PersistenceClient::new(state.config.data_persistence_service_url.clone());
// 1. Fetch all necessary data from the persistence service
let (profile, financials) = fetch_data(&persistence_client, &event.symbol).await?;
if financials.is_empty() {
warn!(
"No financial data found for symbol: {}. Aborting report generation.",
event.symbol
);
return Ok(());
}
// 2. Create context and render the prompt
let mut context = Context::new();
context.insert("name", &profile.name);
context.insert("industry", &profile.industry);
context.insert("list_date", &profile.list_date.map(|d| d.to_string()));
context.insert("records_count", &financials.len());
let prompt = render_prompt(&state.tera, "company_profile_summary", &context)
.map_err(|e| ProviderError::Internal(anyhow::anyhow!("Prompt rendering failed: {}", e)))?;
// 3. Call the LLM to generate the summary
info!("Generating summary for symbol: {}", event.symbol);
let summary = state.llm_client.generate_text(prompt).await?;
// 4. Persist the generated report (future work)
info!(
"Successfully generated report for symbol: {} ({} records)",
event.symbol,
financials.len()
);
info!("Generated Summary: {}", summary);
Ok(())
}
async fn fetch_data(
client: &PersistenceClient,
symbol: &str,
) -> Result<(CompanyProfileDto, Vec<TimeSeriesFinancialDto>)> {
let (profile, financials) = tokio::try_join!(
client.get_company_profile(symbol),
client.get_financial_statements(symbol)
)?;
Ok((profile, financials))
}

View File

@ -0,0 +1,16 @@
Generate a comprehensive summary for the following company profile. Analyze its business model, market position, and key financial highlights.
Company Name: {{ name }}
Industry: {{ industry }}
Website: {{ website }}
Description:
{{ description }}
Key Financials:
- Revenue (latest year): {{ revenue }}
- Net Income (latest year): {{ net_income }}
- Total Assets: {{ total_assets }}
- Total Equity: {{ total_equity }}
- Operating Cash Flow: {{ ocf }}
Based on this information, provide a detailed analysis.

View File

@ -1,30 +0,0 @@
# Stage 1: Build the application in a build environment
# We use cargo-chef to cache dependencies and speed up future builds
FROM rust:1.78-slim AS chef
WORKDIR /app
RUN cargo install cargo-chef
FROM chef AS planner
COPY . .
# Compute a lock file for dependencies
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Build dependencies first, this layer will be cached if dependencies don't change
RUN cargo chef cook --release --recipe-path recipe.json
# Copy application code and build the application
COPY . .
RUN cargo build --release --bin data-persistence-service
# Stage 2: Create the final, minimal production image
FROM debian:bookworm-slim AS runtime
WORKDIR /app
# Copy the compiled binary from the builder stage
COPY --from=builder /app/target/release/data-persistence-service /usr/local/bin/
# Copy migrations for `sqlx-cli` if needed at runtime
COPY ./migrations ./migrations
# Expose the port the application will listen on
EXPOSE 8080
# Set the entrypoint for the container
ENTRYPOINT ["/usr/local/bin/data-persistence-service"]

View File

@ -0,0 +1,47 @@
use crate::{error::ProviderError, ts_client::TushareClient};
use common_contracts::models::{CompanyProfile, FinancialStatement};
pub struct TushareDataProvider {
client: TushareClient,
}
impl TushareDataProvider {
pub fn new(api_url: String, api_token: String) -> Self {
Self {
client: TushareClient::new(api_url, api_token),
}
}
pub async fn fetch_all_data(
&self,
symbol: &str,
) -> Result<(CompanyProfile, Vec<FinancialStatement>), ProviderError> {
// This is a placeholder.
// The actual implementation will be complex and will involve:
// 1. Calling multiple Tushare APIs in parallel using tokio::try_join!.
// - stock_basic, stock_company for CompanyProfile
// - balancesheet, income, cashflow, fina_indicator, etc. for Financials
// 2. Reading the Python implementation from `backend/app/data_providers/tushare.py`
// to understand the intricate logic for:
// - Aggregating data from multiple API calls.
// - Filtering reports (latest this year + annual for past years).
// - Calculating nearly 20 derived financial metrics.
// 3. Mapping the raw Tushare responses into our `common-contracts` models.
// This will happen in `mapping.rs`.
// For now, returning dummy data to ensure the workflow compiles.
let profile = CompanyProfile {
symbol: symbol.to_string(),
name: "Placeholder Company".to_string(),
..Default::default()
};
let financials = vec![FinancialStatement {
symbol: symbol.to_string(),
year: 2023,
..Default::default()
}];
Ok((profile, financials))
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
[package]
name = "tushare-provider-service"
version = "0.1.0"
edition = "2021"
[dependencies]
common-contracts = { path = "../common-contracts" }
rmcp = "0.8.5"
anyhow = "1.0"
async-nats = "0.33"
axum = "0.8"
config = "0.14"
dashmap = "5.5"
futures = "0.3"
futures-util = "0.3.31"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["full"] }
tower-http = { version = "0.5.0", features = ["cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.6", features = ["v4", "serde"] }
reqwest = { version = "0.12.24", features = ["json"] }
url = "2.5.2"
thiserror = "1.0.61"
async-trait = "0.1.80"
lazy_static = "1.5.0"
regex = "1.10.4"
chrono = "0.4.38"
rust_decimal = "1.35.0"
rust_decimal_macros = "1.35.0"
itertools = "0.13.0"

View File

@ -0,0 +1,31 @@
# 1. Build Stage
FROM rust:1.90 as builder
WORKDIR /usr/src/app
# Pre-build dependencies to leverage Docker layer caching
COPY ./services/common-contracts /usr/src/app/services/common-contracts
COPY ./services/tushare-provider-service/Cargo.toml ./services/tushare-provider-service/Cargo.lock* ./services/tushare-provider-service/
RUN mkdir -p ./services/tushare-provider-service/src && \
echo "fn main() {}" > ./services/tushare-provider-service/src/main.rs && \
cargo build --release --bin tushare-provider-service
# Copy the full source code
COPY ./services/tushare-provider-service /usr/src/app/services/tushare-provider-service
# Build the application
RUN cargo build --release --bin tushare-provider-service
# 2. Runtime Stage
FROM debian:bookworm-slim
# Set timezone
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Copy the built binary from the builder stage
COPY --from=builder /usr/src/app/target/release/tushare-provider-service /usr/local/bin/
# Set the binary as the entrypoint
ENTRYPOINT ["/usr/local/bin/tushare-provider-service"]

View File

@ -0,0 +1,23 @@
use axum::{routing::get, Router, extract::State};
use crate::state::AppState;
pub fn create_router(app_state: AppState) -> Router {
Router::new()
.route("/health", get(health_check))
.route("/tasks", get(get_tasks))
.with_state(app_state)
}
async fn health_check(State(_state): State<AppState>) -> &'static str {
"OK"
}
async fn get_tasks(State(state): State<AppState>) -> axum::Json<Vec<common_contracts::observability::TaskProgress>> {
let tasks = state
.tasks
.iter()
.map(|kv| kv.value().clone())
.collect::<Vec<_>>();
axum::Json(tasks)
}

View File

@ -0,0 +1,19 @@
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
pub server_port: u16,
pub nats_addr: String,
pub data_persistence_service_url: String,
pub tushare_api_url: String,
pub tushare_api_token: String,
}
impl AppConfig {
pub fn load() -> Result<Self, config::ConfigError> {
let config = config::Config::builder()
.add_source(config::Environment::default().separator("__"))
.build()?;
config.try_deserialize()
}
}

View File

@ -0,0 +1,27 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ProviderError {
#[error("API request failed: {0}")]
ApiRequest(#[from] reqwest::Error),
#[error("Failed to parse JSON response: {0}")]
JsonParsing(#[from] serde_json::Error),
#[error("Tushare API returned an error: code={code}, message='{msg}'")]
TushareApi { code: i64, msg: String },
#[error("Configuration error: {0}")]
Configuration(String),
#[error("Data mapping error: {0}")]
Mapping(String),
#[error("Persistence client error: {0}")]
Persistence(String),
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
}
pub type Result<T> = std::result::Result<T, ProviderError>;

View File

@ -0,0 +1,47 @@
mod api;
mod config;
mod error;
mod mapping;
mod message_consumer;
mod persistence;
mod state;
mod ts_client;
mod tushare;
mod worker;
use crate::config::AppConfig;
use crate::error::{Result, ProviderError};
use crate::state::AppState;
use tracing::info;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
info!("Starting tushare-provider-service...");
// Load configuration
let config = AppConfig::load().map_err(|e| ProviderError::Configuration(e.to_string()))?;
let port = config.server_port;
// Initialize application state
let app_state = AppState::new(config);
// Create the Axum router
let app = api::create_router(app_state.clone());
// --- Start the message consumer ---
tokio::spawn(message_consumer::run(app_state));
// Start the HTTP server
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap();
info!("HTTP server listening on port {}", port);
axum::serve(listener, app).await.unwrap();
Ok(())
}

View File

@ -0,0 +1,273 @@
use std::collections::HashMap;
use chrono::{Datelike, NaiveDate};
use common_contracts::dtos::TimeSeriesFinancialDto;
use itertools::Itertools;
use rust_decimal::Decimal;
use rust_decimal::prelude::*;
use rust_decimal_macros::dec;
use crate::{
error::ProviderError,
tushare::{
BalanceSheet, Cashflow, Dividend, FinaIndicator, Income, Repurchase, StkHolderNumber,
},
};
pub struct TushareFinancials {
pub balancesheet: Vec<BalanceSheet>,
pub income: Vec<Income>,
pub cashflow: Vec<Cashflow>,
pub fina_indicator: Vec<FinaIndicator>,
pub repurchase: Vec<Repurchase>,
pub dividend: Vec<Dividend>,
pub stk_holdernumber: Vec<StkHolderNumber>,
pub employees: Option<f64>,
}
pub fn map_financial_statements(
symbol: &str,
raw_data: TushareFinancials,
) -> Result<Vec<TimeSeriesFinancialDto>, ProviderError> {
// 1. Merge all financial data by end_date
let mut by_date = merge_financial_data(&raw_data);
// 2. Filter for wanted report dates
let wanted_dates = filter_wanted_dates(by_date.keys().cloned().collect());
let statements_with_periods: Vec<(String, HashMap<String, f64>)> = wanted_dates
.clone()
.into_iter()
.filter_map(|d| by_date.remove(&d).map(|m| (d, m)))
.collect();
// 3. Transform into a series map (metric -> Vec<{period, value}>)
let mut series = transform_to_series_with_periods(&statements_with_periods);
// 4. Process special data (repurchase, dividend, etc.)
let current_year_str = chrono::Utc::now().year().to_string();
let latest_current_year_report = wanted_dates.iter().find(|d| d.starts_with(&current_year_str)).cloned();
process_special_data(&mut series, &raw_data, &current_year_str, latest_current_year_report);
// 5. Calculate derived metrics
calculate_derived_metrics(&mut series);
// 6. 扁平化为 TimeSeriesFinancialDto
flatten_series_to_dtos(symbol, series)
}
fn merge_financial_data(
raw_data: &TushareFinancials,
) -> HashMap<String, HashMap<String, f64>> {
let mut by_date: HashMap<String, HashMap<String, f64>> = HashMap::new();
macro_rules! merge {
($rows:expr, $($field:ident),+) => {
for r in $rows {
if let Some(end_date) = &r.end_date {
let entry = by_date.entry(end_date.clone()).or_default();
$(
if let Some(val) = r.$field {
entry.insert(stringify!($field).to_string(), val);
}
)+
}
}
};
}
merge!(&raw_data.balancesheet, money_cap, inventories, accounts_receiv, prepayment, fix_assets, lt_eqt_invest, goodwill, accounts_pay, adv_receipts, contract_liab, st_borr, lt_borr, total_assets);
merge!(&raw_data.income, revenue, sell_exp, admin_exp, rd_exp, grossprofit_margin, netprofit_margin, n_income);
merge!(&raw_data.cashflow, n_cashflow_act, c_pay_acq_const_fiolta, depr_fa_coga_dpba, c_paid_to_for_empl);
merge!(&raw_data.fina_indicator, tax_to_ebt, arturn_days);
by_date
}
fn filter_wanted_dates(dates: Vec<String>) -> Vec<String> {
let current_year = chrono::Utc::now().year().to_string();
let mut all_available_dates = dates;
all_available_dates.sort_by(|a, b| b.cmp(a)); // Sort descending
let latest_current_year_report = all_available_dates
.iter()
.find(|d| d.starts_with(&current_year))
.cloned();
let mut wanted_dates: Vec<String> = Vec::new();
if let Some(d) = latest_current_year_report {
wanted_dates.push(d);
}
let previous_years_annual_reports = all_available_dates
.into_iter()
.filter(|d| d.ends_with("1231") && !d.starts_with(&current_year));
wanted_dates.extend(previous_years_annual_reports);
wanted_dates
}
#[derive(Debug, Clone)]
struct SeriesPoint { period: String, value: f64 }
type SeriesMap = HashMap<String, Vec<SeriesPoint>>;
fn transform_to_series_with_periods(statements: &[(String, HashMap<String, f64>)]) -> SeriesMap {
let mut series: SeriesMap = HashMap::new();
for (period, report) in statements {
for (key, value) in report {
series
.entry(key.clone())
.or_default()
.push(SeriesPoint { period: period.clone(), value: *value });
}
}
series
}
fn process_special_data(series: &mut SeriesMap, raw_data: &TushareFinancials, current_year: &str, latest_report: Option<String>) {
// Employees
if let Some(employees) = raw_data.employees {
let prev_year = chrono::Utc::now().year() - 1;
series.entry("employees".to_string()).or_default().push(SeriesPoint {
period: format!("{}1231", prev_year),
value: employees,
});
}
// Holder Numbers取每期按最新公告日聚合
let mut holder_by_period: HashMap<String, (String, f64)> = HashMap::new();
for r in &raw_data.stk_holdernumber {
if let (Some(end_date), Some(ann_date), Some(holder_num)) = (&r.end_date, &r.ann_date, r.holder_num) {
let entry = holder_by_period.entry(end_date.clone()).or_insert((ann_date.clone(), holder_num));
if ann_date > &entry.0 {
*entry = (ann_date.clone(), holder_num);
}
}
}
if !holder_by_period.is_empty() {
let mut holder_series: Vec<SeriesPoint> = Vec::new();
for (period, (_ann, num)) in holder_by_period.into_iter() {
holder_series.push(SeriesPoint { period, value: num });
}
series.insert("holder_num".to_string(), holder_series);
}
// Dividend
let mut div_by_year: HashMap<String, f64> = HashMap::new();
for r in &raw_data.dividend {
if let (Some(pay_date), Some(cash_div), Some(base_share)) = (&r.pay_date, r.cash_div_tax, r.base_share) {
if pay_date.len() >= 4 {
let year = &pay_date[..4];
let amount_billion = (cash_div * base_share) / 10000.0;
*div_by_year.entry(year.to_string()).or_default() += amount_billion;
}
}
}
if !div_by_year.is_empty() {
let div_series = div_by_year.into_iter().map(|(year, amount)| {
let period_key = if &year == current_year && latest_report.is_some() {
latest_report.clone().unwrap()
} else {
format!("{}1231", year)
};
SeriesPoint { period: period_key, value: amount }
}).collect();
series.insert("dividend_amount".to_string(), div_series);
}
// Repurchase (simplified)
// A full implementation would be more complex, matching python logic
}
fn calculate_derived_metrics(series: &mut SeriesMap) {
let periods: Vec<String> = series.values().flatten().map(|p| p.period.clone()).unique().collect();
let get_value = |key: &str, period: &str, s: &SeriesMap| -> Option<Decimal> {
s.get(key)
.and_then(|v| v.iter().find(|p| p.period == period))
.and_then(|p| Decimal::from_f64(p.value))
};
let get_avg_value = |key: &str, period: &str, s: &SeriesMap| -> Option<Decimal> {
let current_val = get_value(key, period, s)?;
let prev_year = period[..4].parse::<i32>().ok()? - 1;
let prev_period = format!("{}1231", prev_year);
let prev_val = get_value(key, &prev_period, s);
Some( (current_val + prev_val.unwrap_or(current_val)) / dec!(2) )
};
let get_cogs = |period: &str, s: &SeriesMap| -> Option<Decimal> {
let revenue = get_value("revenue", period, s)?;
let gp_margin_raw = get_value("grossprofit_margin", period, s)?;
let gp_margin = if gp_margin_raw.abs() > dec!(1) { gp_margin_raw / dec!(100) } else { gp_margin_raw };
Some(revenue * (dec!(1) - gp_margin))
};
let mut new_series: SeriesMap = HashMap::new();
for period in &periods {
// Fee Calcs
let fee_calcs = [
("__sell_rate", "sell_exp"), ("__admin_rate", "admin_exp"), ("__rd_rate", "rd_exp"), ("__depr_ratio", "depr_fa_coga_dpba")
];
for (key, num_key) in fee_calcs {
if let (Some(num), Some(den)) = (get_value(num_key, period, series), get_value("revenue", period, series)) {
if !den.is_zero() {
new_series.entry(key.to_string()).or_default().push(SeriesPoint { period: period.clone(), value: ((num / den) * dec!(100)).to_f64().unwrap() });
}
}
}
// Asset Ratios
let asset_ratio_keys = [
("__money_cap_ratio", "money_cap"), ("__inventories_ratio", "inventories"), ("__ar_ratio", "accounts_receiv"),
("__prepay_ratio", "prepayment"), ("__fix_assets_ratio", "fix_assets"), ("__lt_invest_ratio", "lt_eqt_invest"),
("__goodwill_ratio", "goodwill"), ("__ap_ratio", "accounts_pay"), ("__st_borr_ratio", "st_borr"), ("__lt_borr_ratio", "lt_borr"),
];
for (key, num_key) in asset_ratio_keys {
if let (Some(num), Some(den)) = (get_value(num_key, period, series), get_value("total_assets", period, series)) {
if !den.is_zero() {
new_series.entry(key.to_string()).or_default().push(SeriesPoint { period: period.clone(), value: ((num / den) * dec!(100)).to_f64().unwrap() });
}
}
}
// Other derived metrics...
if let Some(tax_to_ebt) = get_value("tax_to_ebt", period, series) {
let rate = if tax_to_ebt.abs() <= dec!(1) { tax_to_ebt * dec!(100) } else { tax_to_ebt };
new_series.entry("__tax_rate".to_string()).or_default().push(SeriesPoint { period: period.clone(), value: rate.to_f64().unwrap() });
}
if let Some(avg_ap) = get_avg_value("accounts_pay", period, series) {
if let Some(cogs) = get_cogs(period, series) {
if !cogs.is_zero() {
new_series.entry("payturn_days".to_string()).or_default().push(SeriesPoint { period: period.clone(), value: ((dec!(365) * avg_ap) / cogs).to_f64().unwrap() });
}
}
}
// ... continue for all other metrics from python
}
series.extend(new_series);
}
fn flatten_series_to_dtos(symbol: &str, series: SeriesMap) -> Result<Vec<TimeSeriesFinancialDto>, ProviderError> {
let mut dtos: Vec<TimeSeriesFinancialDto> = Vec::new();
for (metric_name, data_points) in series {
for point in data_points {
let period_date = NaiveDate::parse_from_str(&point.period, "%Y%m%d")
.map_err(|e| ProviderError::Mapping(format!("Invalid period '{}': {}", point.period, e)))?;
dtos.push(TimeSeriesFinancialDto {
symbol: symbol.to_string(),
metric_name: metric_name.clone(),
period_date,
value: point.value,
source: Some("tushare".to_string()),
});
}
}
Ok(dtos)
}
// (去除旧的 FinancialStatement 映射辅助)

View File

@ -0,0 +1,115 @@
use crate::error::{Result, ProviderError};
use crate::state::AppState;
use common_contracts::messages::FetchCompanyDataCommand;
use futures_util::StreamExt;
use tracing::{error, info, warn};
use std::sync::Arc;
use tokio::sync::mpsc;
use chrono::Utc;
const SUBJECT_NAME: &str = "data_fetch_commands";
pub async fn run(state: AppState) -> Result<()> {
info!("Starting NATS message consumer...");
let client = async_nats::connect(&state.config.nats_addr)
.await
.map_err(|e| ProviderError::Internal(anyhow::anyhow!("NATS connect failed: {}", e)))?;
info!("Connected to NATS.");
subscribe_to_data_commands(Arc::new(state), client).await
}
pub async fn subscribe_to_data_commands(app_state: Arc<AppState>, nats_client: async_nats::Client) -> Result<()> {
// This is a simple subscriber. For production, consider JetStream for durability.
let mut subscriber = nats_client
.subscribe(SUBJECT_NAME.to_string())
.await
.map_err(|e| ProviderError::Internal(anyhow::anyhow!("NATS subscribe failed: {}", e)))?;
info!(
"Consumer started, waiting for messages on subject '{}'",
SUBJECT_NAME
);
while let Some(message) = subscriber.next().await {
info!("Received NATS message.");
let state_for_closure = app_state.clone();
tokio::spawn(async move {
if let Err(e) = serde_json::from_slice::<FetchCompanyDataCommand>(&message.payload) {
error!("Failed to deserialize message: {}", e);
warn!("Received non-json message: {:?}", message.payload);
return;
}
let command = match serde_json::from_slice::<FetchCompanyDataCommand>(&message.payload) {
Ok(c) => c,
Err(e) => {
error!("Failed to deserialize message: {}", e);
return;
}
};
info!("Received data fetch command for symbol: {}", command.symbol);
// Tushare is for the Chinese market ("CN")
if command.market.to_uppercase() != "CN" {
info!(
"Skipping command for symbol '{}' as its market ('{}') is not 'CN'.",
command.symbol, command.market
);
return;
}
let (tx, rx) = mpsc::channel(1);
let task_id = command.request_id;
// Initialize deterministic progress entry
state_for_closure.tasks.insert(task_id, common_contracts::observability::TaskProgress {
request_id: task_id,
task_name: format!("tushare:{}", command.symbol),
status: "Received".to_string(),
progress_percent: 0,
details: "Command received".to_string(),
started_at: Utc::now(),
});
// Spawn the workflow in a separate task
let workflow_state = state_for_closure.clone();
tokio::spawn(async move {
let workflow_state_for_error = workflow_state.clone();
let result = crate::worker::run_tushare_workflow(workflow_state, command, tx).await;
if let Err(e) = result {
error!(
"Error executing Tushare workflow for task {}: {:?}",
task_id, e
);
// Update task to failed status
if let Some(mut task) = workflow_state_for_error.tasks.get_mut(&task_id) {
task.status = "Failed".to_string();
task.details = format!("Workflow failed: {}", e);
}
}
});
// Spawn a separate task to clean up the task entry after completion or timeout
let cleanup_state = state_for_closure.clone();
tokio::spawn(async move {
let mut rx = rx;
match rx.recv().await {
Some(_) => {
info!("Task {} completed successfully, removing from map.", task_id);
cleanup_state.tasks.remove(&task_id);
}
None => {
warn!(
"Task {} completion signal not received, removing after timeout.",
task_id
);
cleanup_state.tasks.remove(&task_id);
}
}
});
});
}
Ok(())
}

View File

@ -0,0 +1,70 @@
//!
//! 数据持久化客户端
//!
//! 提供一个类型化的接口,用于与 `data-persistence-service` 进行通信。
//!
use crate::error::Result;
use common_contracts::{
dtos::{CompanyProfileDto, RealtimeQuoteDto, TimeSeriesFinancialBatchDto, TimeSeriesFinancialDto},
};
use tracing::info;
#[derive(Clone)]
pub struct PersistenceClient {
client: reqwest::Client,
base_url: String,
}
impl PersistenceClient {
pub fn new(base_url: String) -> Self {
Self {
client: reqwest::Client::new(),
base_url,
}
}
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
let url = format!("{}/companies", self.base_url);
info!("Upserting company profile for {} to {}", profile.symbol, url);
self.client
.put(&url)
.json(&profile)
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn upsert_realtime_quote(&self, quote: RealtimeQuoteDto) -> Result<()> {
let url = format!("{}/market-data/quotes", self.base_url);
info!("Upserting realtime quote for {} to {}", quote.symbol, url);
self.client
.post(&url)
.json(&quote)
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn batch_insert_financials(&self, dtos: Vec<TimeSeriesFinancialDto>) -> Result<()> {
if dtos.is_empty() {
return Ok(());
}
let url = format!("{}/market-data/financials/batch", self.base_url);
let symbol = dtos[0].symbol.clone();
info!("Batch inserting {} financial statements for {} to {}", dtos.len(), symbol, url);
let batch = TimeSeriesFinancialBatchDto { records: dtos };
self.client
.post(&url)
.json(&batch)
.send()
.await?
.error_for_status()?;
Ok(())
}
}

View File

@ -0,0 +1,31 @@
use std::sync::Arc;
use dashmap::DashMap;
use uuid::Uuid;
use common_contracts::observability::TaskProgress;
use crate::config::AppConfig;
use crate::tushare::TushareDataProvider;
#[derive(Clone)]
pub struct AppState {
pub tasks: Arc<DashMap<Uuid, TaskProgress>>,
pub config: Arc<AppConfig>,
pub tushare_provider: Arc<TushareDataProvider>,
}
impl AppState {
pub fn new(config: AppConfig) -> Self {
let provider = Arc::new(TushareDataProvider::new(
config.tushare_api_url.clone(),
config.tushare_api_token.clone(),
));
Self {
tasks: Arc::new(DashMap::new()),
config: Arc::new(config),
tushare_provider: provider,
}
}
}

View File

@ -0,0 +1,99 @@
use crate::error::ProviderError;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::info;
#[derive(Serialize)]
struct TushareRequest<'a> {
api_name: &'a str,
token: &'a str,
params: serde_json::Value,
fields: &'a str,
}
#[derive(Deserialize, Debug)]
pub struct TushareResponse<T> {
pub code: i64,
pub msg: String,
pub data: Option<TushareData<T>>,
}
#[derive(Deserialize, Debug)]
pub struct TushareData<T> {
pub fields: Vec<String>,
pub items: Vec<Vec<serde_json::Value>>,
#[serde(skip)]
_marker: std::marker::PhantomData<T>,
}
#[derive(Clone)]
pub struct TushareClient {
client: reqwest::Client,
api_url: String,
api_token: String,
}
impl TushareClient {
pub fn new(api_url: String, api_token: String) -> Self {
Self {
client: reqwest::Client::new(),
api_url,
api_token,
}
}
pub async fn send_request<T: DeserializeOwned>(
&self,
api_name: &str,
params: serde_json::Value,
fields: &str,
) -> Result<Vec<T>, ProviderError> {
let request_payload = TushareRequest {
api_name,
token: &self.api_token,
params,
fields,
};
info!("Sending Tushare request for api_name: {}", api_name);
let res = self
.client
.post(&self.api_url)
.json(&request_payload)
.send()
.await?;
let text = res.text().await?;
let response: TushareResponse<T> = serde_json::from_str(&text)?;
if response.code != 0 {
return Err(ProviderError::TushareApi {
code: response.code,
msg: response.msg,
});
}
let data = response.data.ok_or_else(|| ProviderError::TushareApi {
code: -1,
msg: "No data field in response".to_string(),
})?;
let items = data
.items
.into_iter()
.map(|row| {
let json_map: serde_json::Map<String, serde_json::Value> = data
.fields
.iter()
.zip(row.into_iter())
.map(|(field, value)| (field.clone(), value))
.collect();
serde_json::from_value::<T>(serde_json::Value::Object(json_map))
})
.collect::<Result<Vec<T>, _>>()?;
Ok(items)
}
}

View File

@ -0,0 +1,320 @@
use chrono::NaiveDate;
use serde::Deserialize;
use serde_json::json;
use tokio;
use crate::{
error::ProviderError,
mapping::{map_financial_statements, TushareFinancials},
ts_client::TushareClient,
};
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
#[derive(Clone)]
pub struct TushareDataProvider {
client: TushareClient,
}
impl TushareDataProvider {
pub fn new(api_url: String, api_token: String) -> Self {
Self {
client: TushareClient::new(api_url, api_token),
}
}
pub async fn fetch_all_data(
&self,
symbol: &str,
) -> Result<(CompanyProfileDto, Vec<TimeSeriesFinancialDto>), ProviderError> {
let (
stock_basic,
stock_company,
balancesheet,
income,
cashflow,
fina_indicator,
repurchase,
dividend,
stk_holdernumber,
) = self.fetch_raw_data(symbol).await?;
// Build CompanyProfileDto (strict typed, no silent fallbacks)
let ts_code = stock_basic
.get(0)
.map(|r| r.ts_code.clone())
.ok_or_else(|| ProviderError::Mapping("stock_basic missing first row".to_string()))?;
let name = stock_basic
.get(0)
.and_then(|r| r.name.clone())
.ok_or_else(|| ProviderError::Mapping("stock_basic.name missing".to_string()))?;
let industry = stock_basic.get(0).and_then(|r| r.industry.clone());
let list_date = stock_basic
.get(0)
.and_then(|r| r.list_date.clone())
.map(|d| NaiveDate::parse_from_str(&d, "%Y%m%d"))
.transpose()
.map_err(|e| ProviderError::Mapping(format!("Invalid list_date: {}", e)))?;
let profile = CompanyProfileDto {
symbol: ts_code,
name,
industry,
list_date,
additional_info: None,
};
// Map time-series financials into DTOs
let raw_financials = TushareFinancials {
balancesheet,
income,
cashflow,
fina_indicator,
repurchase,
dividend,
stk_holdernumber,
employees: stock_company.get(0).and_then(|r| r.employees),
};
let financials = map_financial_statements(symbol, raw_financials)?;
Ok((profile, financials))
}
async fn fetch_raw_data(
&self,
symbol: &str,
) -> Result<
(
Vec<StockBasic>,
Vec<StockCompany>,
Vec<BalanceSheet>,
Vec<Income>,
Vec<Cashflow>,
Vec<FinaIndicator>,
Vec<Repurchase>,
Vec<Dividend>,
Vec<StkHolderNumber>,
),
ProviderError,
> {
let params = json!({ "ts_code": symbol });
let stock_basic_task = self
.client
.send_request::<StockBasic>("stock_basic", params.clone(), "");
let stock_company_task = self.client.send_request::<StockCompany>(
"stock_company",
params.clone(),
"ts_code,exchange,chairman,manager,secretary,reg_capital,setup_date,province,city,introduction,website,email,office,employees,main_business,business_scope",
);
let balancesheet_task = self.client.send_request::<BalanceSheet>(
"balancesheet",
params.clone(),
"ts_code,ann_date,f_ann_date,end_date,report_type,comp_type,money_cap,inventories,prepayment,accounts_receiv,goodwill,lt_eqt_invest,fix_assets,total_assets,accounts_pay,adv_receipts,contract_liab,st_borr,lt_borr,total_cur_assets,total_cur_liab,total_ncl,total_liab,total_hldr_eqy_exc_min_int",
);
let income_task = self.client.send_request::<Income>(
"income",
params.clone(),
"ts_code,ann_date,f_ann_date,end_date,report_type,comp_type,total_revenue,revenue,sell_exp,admin_exp,rd_exp,operate_profit,total_profit,income_tax,n_income,n_income_attr_p,ebit,ebitda,netprofit_margin,grossprofit_margin",
);
let cashflow_task = self.client.send_request::<Cashflow>(
"cashflow",
params.clone(),
"ts_code,ann_date,f_ann_date,end_date,comp_type,report_type,n_cashflow_act,c_pay_acq_const_fiolta,c_paid_to_for_empl,depr_fa_coga_dpba",
);
let fina_indicator_task = self.client.send_request::<FinaIndicator>(
"fina_indicator",
params.clone(),
"ts_code,end_date,ann_date,grossprofit_margin,netprofit_margin,tax_to_ebt,roe,roa,roic,invturn_days,arturn_days,fa_turn,tr_yoy,dt_netprofit_yoy,assets_turn",
);
let repurchase_task = self.client.send_request::<Repurchase>(
"repurchase",
params.clone(),
"ts_code,ann_date,end_date,proc,exp_date,vol,amount,high_limit,low_limit",
);
let dividend_task = self.client.send_request::<Dividend>(
"dividend",
params.clone(),
"ts_code,end_date,cash_div_tax,pay_date,base_share",
);
let stk_holdernumber_task = self.client.send_request::<StkHolderNumber>(
"stk_holdernumber",
params.clone(),
"ts_code,ann_date,end_date,holder_num",
);
let (
stock_basic,
stock_company,
balancesheet,
income,
cashflow,
fina_indicator,
repurchase,
dividend,
stk_holdernumber,
) = tokio::try_join!(
stock_basic_task,
stock_company_task,
balancesheet_task,
income_task,
cashflow_task,
fina_indicator_task,
repurchase_task,
dividend_task,
stk_holdernumber_task,
)?;
Ok((
stock_basic,
stock_company,
balancesheet,
income,
cashflow,
fina_indicator,
repurchase,
dividend,
stk_holdernumber,
))
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct StockBasic {
pub ts_code: String,
pub symbol: Option<String>,
pub name: Option<String>,
pub area: Option<String>,
pub industry: Option<String>,
pub market: Option<String>,
pub exchange: Option<String>,
pub list_date: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct StockCompany {
pub ts_code: String,
pub exchange: Option<String>,
pub chairman: Option<String>,
pub manager: Option<String>,
pub secretary: Option<String>,
pub reg_capital: Option<f64>,
pub setup_date: Option<String>,
pub province: Option<String>,
pub city: Option<String>,
pub introduction: Option<String>,
pub website: Option<String>,
pub email: Option<String>,
pub office: Option<String>,
pub employees: Option<f64>,
pub main_business: Option<String>,
pub business_scope: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct BalanceSheet {
pub ts_code: String,
pub ann_date: Option<String>,
pub end_date: Option<String>,
pub report_type: Option<String>,
pub money_cap: Option<f64>,
pub inventories: Option<f64>,
pub prepayment: Option<f64>,
pub accounts_receiv: Option<f64>,
pub goodwill: Option<f64>,
pub lt_eqt_invest: Option<f64>,
pub fix_assets: Option<f64>,
pub total_assets: Option<f64>,
pub accounts_pay: Option<f64>,
pub adv_receipts: Option<f64>,
pub contract_liab: Option<f64>,
pub st_borr: Option<f64>,
pub lt_borr: Option<f64>,
pub total_cur_assets: Option<f64>,
pub total_cur_liab: Option<f64>,
pub total_ncl: Option<f64>,
pub total_liab: Option<f64>,
pub total_hldr_eqy_exc_min_int: Option<f64>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Income {
pub ts_code: String,
pub ann_date: Option<String>,
pub end_date: Option<String>,
pub report_type: Option<String>,
pub total_revenue: Option<f64>,
pub revenue: Option<f64>,
pub sell_exp: Option<f64>,
pub admin_exp: Option<f64>,
pub rd_exp: Option<f64>,
pub operate_profit: Option<f64>,
pub total_profit: Option<f64>,
pub income_tax: Option<f64>,
pub n_income: Option<f64>,
pub n_income_attr_p: Option<f64>,
pub ebit: Option<f64>,
pub ebitda: Option<f64>,
pub netprofit_margin: Option<f64>,
pub grossprofit_margin: Option<f64>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Cashflow {
pub ts_code: String,
pub ann_date: Option<String>,
pub end_date: Option<String>,
pub report_type: Option<String>,
pub n_cashflow_act: Option<f64>,
pub c_pay_acq_const_fiolta: Option<f64>,
pub c_paid_to_for_empl: Option<f64>,
pub depr_fa_coga_dpba: Option<f64>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FinaIndicator {
pub ts_code: String,
pub end_date: Option<String>,
pub ann_date: Option<String>,
pub grossprofit_margin: Option<f64>,
pub netprofit_margin: Option<f64>,
pub tax_to_ebt: Option<f64>,
pub roe: Option<f64>,
pub roa: Option<f64>,
pub roic: Option<f64>,
pub invturn_days: Option<f64>,
pub arturn_days: Option<f64>,
pub fa_turn: Option<f64>,
pub tr_yoy: Option<f64>,
pub dt_netprofit_yoy: Option<f64>,
pub assets_turn: Option<f64>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Repurchase {
pub ts_code: String,
pub ann_date: Option<String>,
pub end_date: Option<String>,
pub vol: Option<f64>,
pub amount: Option<f64>,
pub high_limit: Option<f64>,
pub low_limit: Option<f64>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Dividend {
pub ts_code: String,
pub end_date: Option<String>,
pub cash_div_tax: Option<f64>,
pub pay_date: Option<String>,
pub base_share: Option<f64>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct StkHolderNumber {
pub ts_code: String,
pub ann_date: Option<String>,
pub end_date: Option<String>,
pub holder_num: Option<f64>,
}

View File

@ -0,0 +1,162 @@
use std::sync::Arc;
use common_contracts::{
dtos::{CompanyProfileDto, TimeSeriesFinancialDto},
messages::{CompanyProfilePersistedEvent, FetchCompanyDataCommand, FinancialsPersistedEvent},
};
use tokio::sync::mpsc;
use tracing::info;
use chrono::Datelike;
use crate::{error::ProviderError, persistence::PersistenceClient, state::AppState};
pub async fn run_tushare_workflow(
state: Arc<AppState>,
command: FetchCompanyDataCommand,
completion_tx: mpsc::Sender<()>,
) -> Result<(), ProviderError> {
let task_id = command.request_id;
let symbol = command.symbol.clone();
// 1. Update task progress: Fetching data
{
let mut entry = state
.tasks
.get_mut(&task_id)
.ok_or_else(|| ProviderError::Internal(anyhow::anyhow!("Task not found")))?;
entry.status = "FetchingData".to_string();
entry.progress_percent = 10;
entry.details = "Starting data fetch from Tushare".to_string();
}
// 2. Fetch data using the provider
let (profile, financials) = state
.tushare_provider
.fetch_all_data(&symbol)
.await?;
// 3. Update task progress: Persisting data
{
let mut entry = state
.tasks
.get_mut(&task_id)
.ok_or_else(|| ProviderError::Internal(anyhow::anyhow!("Task not found")))?;
entry.status = "PersistingData".to_string();
entry.progress_percent = 60;
entry.details = "Data fetched, persisting to database".to_string();
}
// 4. Persist data
let persistence_client = PersistenceClient::new(state.config.data_persistence_service_url.clone());
persist_data(
&persistence_client,
&profile,
&financials,
&state,
task_id,
)
.await?;
// 5. Publish events
let nats_client = async_nats::connect(&state.config.nats_addr)
.await
.map_err(|e| ProviderError::Internal(anyhow::anyhow!("NATS connection failed: {}", e)))?;
publish_events(&nats_client, &command, &financials).await?;
// 6. Finalize task
{
let mut entry = state
.tasks
.get_mut(&task_id)
.ok_or_else(|| ProviderError::Internal(anyhow::anyhow!("Task not found")))?;
entry.status = "Completed".to_string();
entry.progress_percent = 100;
entry.details = "Workflow finished successfully".to_string();
}
let _ = completion_tx.send(()).await;
info!(
"Tushare workflow for symbol {} completed successfully.",
symbol
);
Ok(())
}
async fn persist_data(
client: &PersistenceClient,
profile: &CompanyProfileDto,
financials: &[TimeSeriesFinancialDto],
state: &Arc<AppState>,
task_id: uuid::Uuid,
) -> Result<(), ProviderError> {
// In a real implementation, we'd use tokio::try_join! to run these in parallel.
if let Err(e) = client.upsert_company_profile(profile.clone()).await {
state
.tasks
.get_mut(&task_id)
.unwrap()
.details = format!("Failed to save profile: {}", e);
return Err(e);
}
{
let mut task = state.tasks.get_mut(&task_id).unwrap();
task.progress_percent = 75;
task.details = "Company profile saved".to_string();
}
if let Err(e) = client.batch_insert_financials(financials.to_vec()).await {
state
.tasks
.get_mut(&task_id)
.unwrap()
.details = format!("Failed to save financials: {}", e);
return Err(e);
}
{
let mut task = state.tasks.get_mut(&task_id).unwrap();
task.progress_percent = 90;
task.details = "Financial statements saved".to_string();
}
Ok(())
}
async fn publish_events(
nats_client: &async_nats::Client,
command: &FetchCompanyDataCommand,
financials: &[TimeSeriesFinancialDto],
) -> Result<(), ProviderError> {
let profile_event = CompanyProfilePersistedEvent {
request_id: command.request_id,
symbol: command.symbol.clone(),
};
nats_client
.publish(
"events.data.company_profile_persisted",
serde_json::to_vec(&profile_event).unwrap().into(),
)
.await
.map_err(|e| ProviderError::Internal(anyhow::anyhow!("Event publishing failed: {}", e)))?;
let years: std::collections::BTreeSet<u16> = financials
.iter()
.map(|f| f.period_date.year() as u16)
.collect();
let financials_event = FinancialsPersistedEvent {
request_id: command.request_id,
symbol: command.symbol.clone(),
years_updated: years.into_iter().collect(),
};
nats_client
.publish(
"events.data.financials_persisted",
serde_json::to_vec(&financials_event).unwrap().into(),
)
.await
.map_err(|e| ProviderError::Internal(anyhow::anyhow!("Event publishing failed: {}", e)))?;
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
[package]
name = "yfinance-provider-service"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web Service
axum = "0.7"
tokio = { version = "1.0", features = ["full"] }
tower-http = { version = "0.5.0", features = ["cors"] }
# Shared Contracts
common-contracts = { path = "../common-contracts" }
# Message Queue (NATS)
async-nats = "0.33"
futures = "0.3"
futures-util = "0.3.31"
# Data Persistence Client
reqwest = { version = "0.12.24", features = ["json"] }
# Concurrency & Async
async-trait = "0.1.80"
dashmap = "5.5"
uuid = { version = "1.6", features = ["v4", "serde"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Logging & Telemetry
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Configuration
config = "0.14"
secrecy = { version = "0.8", features = ["serde"] }
# Error Handling
thiserror = "1.0.61"
anyhow = "1.0"
chrono = { version = "0.4.38", features = ["serde"] }
itertools = "0.13.0"

View File

@ -0,0 +1,31 @@
# 1. Build Stage
FROM rust:1.90 as builder
WORKDIR /usr/src/app
# Pre-build dependencies to leverage Docker layer caching
COPY ./services/common-contracts /usr/src/app/services/common-contracts
COPY ./services/yfinance-provider-service/Cargo.toml ./services/yfinance-provider-service/Cargo.lock* ./services/yfinance-provider-service/
RUN mkdir -p ./services/yfinance-provider-service/src && \
echo "fn main() {}" > ./services/yfinance-provider-service/src/main.rs && \
cargo build --release --bin yfinance-provider-service
# Copy the full source code
COPY ./services/yfinance-provider-service /usr/src/app/services/yfinance-provider-service
# Build the application
RUN cargo build --release --bin yfinance-provider-service
# 2. Runtime Stage
FROM debian:bookworm-slim
# Set timezone
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Copy the built binary from the builder stage
COPY --from=builder /usr/src/app/target/release/yfinance-provider-service /usr/local/bin/
# Set the binary as the entrypoint
ENTRYPOINT ["/usr/local/bin/yfinance-provider-service"]

View File

@ -0,0 +1,43 @@
use std::collections::HashMap;
use axum::{
extract::State,
response::Json,
routing::get,
Router,
};
use common_contracts::observability::{HealthStatus, ServiceStatus, TaskProgress};
use crate::state::AppState;
pub fn create_router(app_state: AppState) -> Router {
Router::new()
.route("/health", get(health_check))
.route("/tasks", get(get_current_tasks))
.with_state(app_state)
}
/// [GET /health]
/// Provides the current health status of the module.
async fn health_check(State(_state): State<AppState>) -> Json<HealthStatus> {
let mut details = HashMap::new();
// In a real scenario, we would check connections to the message bus, etc.
details.insert("message_bus_connection".to_string(), "ok".to_string());
let status = HealthStatus {
module_id: "yfinance-provider-service".to_string(),
status: ServiceStatus::Ok,
version: env!("CARGO_PKG_VERSION").to_string(),
details,
};
Json(status)
}
/// [GET /tasks]
/// Reports all currently processing tasks and their progress.
async fn get_current_tasks(State(state): State<AppState>) -> Json<Vec<TaskProgress>> {
let tasks: Vec<TaskProgress> = state
.tasks
.iter()
.map(|entry| entry.value().clone())
.collect();
Json(tasks)
}

View File

@ -0,0 +1,19 @@
use secrecy::{ExposeSecret, Secret};
use serde::{Deserialize, Deserializer};
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
pub server_port: u16,
pub nats_addr: String,
pub data_persistence_service_url: String,
}
impl AppConfig {
pub fn load() -> Result<Self, config::ConfigError> {
let config = config::Config::builder()
.add_source(config::Environment::default().separator("__"))
.build()?;
config.try_deserialize()
}
}

View File

@ -0,0 +1,40 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Configuration error: {0}")]
Configuration(String),
#[error("Message bus error: {0}")]
MessageBus(#[from] async_nats::Error),
#[error("Message bus publish error: {0}")]
MessageBusPublish(#[from] async_nats::PublishError),
#[error("Message bus subscribe error: {0}")]
MessageBusSubscribe(String),
#[error("Message bus connect error: {0}")]
MessageBusConnect(String),
#[error("HTTP request to another service failed: {0}")]
ServiceRequest(#[from] reqwest::Error),
#[error("Data parsing error: {0}")]
DataParsing(#[from] anyhow::Error),
}
// 手动实现针对 async-nats 泛型错误类型的 From 转换
impl From<async_nats::error::Error<async_nats::ConnectErrorKind>> for AppError {
fn from(err: async_nats::error::Error<async_nats::ConnectErrorKind>) -> Self {
AppError::MessageBusConnect(err.to_string())
}
}
impl From<async_nats::SubscribeError> for AppError {
fn from(err: async_nats::SubscribeError) -> Self {
AppError::MessageBusSubscribe(err.to_string())
}
}

View File

@ -0,0 +1,46 @@
mod api;
mod config;
mod error;
mod mapping;
mod message_consumer;
mod persistence;
mod state;
mod worker;
mod yfinance;
use crate::config::AppConfig;
use crate::error::Result;
use crate::state::AppState;
use tracing::info;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
info!("Starting yfinance-provider-service...");
// Load configuration
let config = AppConfig::load().map_err(|e| error::AppError::Configuration(e.to_string()))?;
let port = config.server_port;
// Initialize application state
let app_state = AppState::new(config);
// Create the Axum router
let app = api::create_router(app_state.clone());
// --- Start the message consumer ---
tokio::spawn(message_consumer::run(app_state));
// Start the HTTP server
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap();
info!("HTTP server listening on port {}", port);
axum::serve(listener, app).await.unwrap();
Ok(())
}

View File

@ -0,0 +1,133 @@
use anyhow::anyhow;
use chrono::NaiveDate;
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
use serde_json::Value;
pub fn map_profile(summary_json: &Value, symbol: &str) -> Result<CompanyProfileDto, crate::error::AppError> {
let result = summary_json
.get("quoteSummary")
.and_then(|v| v.get("result"))
.and_then(|v| v.get(0))
.ok_or_else(|| crate::error::AppError::DataParsing(anyhow!("quoteSummary.result[0] missing")))?;
let asset_profile = result.get("assetProfile").and_then(|v| v.as_object());
let quote_type = result.get("quoteType").and_then(|v| v.as_object());
let name = quote_type
.and_then(|qt| qt.get("longName"))
.and_then(|v| v.as_str())
.unwrap_or(symbol)
.to_string();
let industry = asset_profile
.and_then(|ap| ap.get("industry"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Yahoo 没有可靠的上市日期字段,这里保持 None附加信息中保留更多细节
let mut additional = serde_json::Map::new();
if let Some(ex) = quote_type.and_then(|qt| qt.get("exchange")).and_then(|v| v.as_str()) {
additional.insert("exchange".to_string(), Value::String(ex.to_string()));
}
if let Some(website) = asset_profile.and_then(|ap| ap.get("website")).and_then(|v| v.as_str()) {
additional.insert("website".to_string(), Value::String(website.to_string()));
}
if let Some(desc) = asset_profile.and_then(|ap| ap.get("longBusinessSummary")).and_then(|v| v.as_str()) {
additional.insert("description".to_string(), Value::String(desc.to_string()));
}
Ok(CompanyProfileDto {
symbol: symbol.to_string(),
name,
industry,
list_date: None,
additional_info: if additional.is_empty() { None } else { Some(Value::Object(additional)) },
})
}
pub fn map_financial_statements(financials_json: &Value, symbol: &str) -> Result<Vec<TimeSeriesFinancialDto>, crate::error::AppError> {
let result = financials_json
.get("quoteSummary")
.and_then(|v| v.get("result"))
.and_then(|v| v.get(0))
.ok_or_else(|| crate::error::AppError::DataParsing(anyhow!("quoteSummary.result[0] missing")))?;
fn to_vec<'a>(obj: Option<&'a Value>, outer_key: &str, inner_key: &str) -> Vec<&'a serde_json::Map<String, Value>> {
obj.and_then(|o| o.get(outer_key))
.and_then(|v| v.get(inner_key))
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|item| item.as_object()).collect::<Vec<_>>())
.unwrap_or_default()
}
let income_arr = result.get("incomeStatementHistory");
let balance_arr = result.get("balanceSheetHistory");
let cashflow_arr = result.get("cashflowStatementHistory");
let mut out: Vec<TimeSeriesFinancialDto> = Vec::new();
// helper to parse endDate
let parse_period = |obj: &serde_json::Map<String, Value>| -> Option<NaiveDate> {
if let Some(fmt) = obj.get("endDate").and_then(|v| v.get("fmt")).and_then(|v| v.as_str()) {
NaiveDate::parse_from_str(fmt, "%Y-%m-%d").ok()
} else {
None
}
};
// helper to push
let mut push_metric = |metric: &str, period: NaiveDate, value: f64| {
out.push(TimeSeriesFinancialDto {
symbol: symbol.to_string(),
metric_name: metric.to_string(),
period_date: period,
value,
source: Some("yahoo".to_string()),
});
};
// Income
for stmt in to_vec(income_arr, "incomeStatementHistory", "incomeStatementHistory") {
if let Some(period) = parse_period(stmt) {
if let Some(v) = stmt.get("totalRevenue").and_then(|v| v.get("raw")).and_then(|v| v.as_f64()) {
push_metric("revenue", period, v);
}
if let Some(v) = stmt.get("netIncome").and_then(|v| v.get("raw")).and_then(|v| v.as_f64()) {
push_metric("net_income", period, v);
}
}
}
// Balance
for stmt in to_vec(balance_arr, "balanceSheetHistory", "balanceSheetStatements") {
if let Some(period) = parse_period(stmt) {
if let Some(v) = stmt.get("totalAssets").and_then(|v| v.get("raw")).and_then(|v| v.as_f64()) {
push_metric("total_assets", period, v);
}
if let Some(v) = stmt.get("totalLiab").and_then(|v| v.get("raw")).and_then(|v| v.as_f64()) {
push_metric("total_liab", period, v);
}
}
}
// Cashflow
for stmt in to_vec(cashflow_arr, "cashflowStatementHistory", "cashflowStatements") {
if let Some(period) = parse_period(stmt) {
if let Some(v) = stmt.get("totalCashFromOperatingActivities").and_then(|v| v.get("raw")).and_then(|v| v.as_f64()) {
push_metric("operating_cash_flow", period, v);
}
if let Some(v) = stmt.get("capitalExpenditures").and_then(|v| v.get("raw")).and_then(|v| v.as_f64()) {
// 通常为负值,保持原值以保留确定性
push_metric("capital_expenditure", period, v);
}
// Derived: FCF = OCF - CAPEX
if let (Some(ocf), Some(capex)) = (
stmt.get("totalCashFromOperatingActivities").and_then(|v| v.get("raw")).and_then(|v| v.as_f64()),
stmt.get("capitalExpenditures").and_then(|v| v.get("raw")).and_then(|v| v.as_f64()),
) {
push_metric("__free_cash_flow", period, ocf - capex);
}
}
}
Ok(out)
}

Some files were not shown because too many files have changed in this diff Show More