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:
parent
0e45dd4a3f
commit
5327e76aaa
2
.gitignore
vendored
2
.gitignore
vendored
@ -17,7 +17,7 @@ services/**/node_modules/
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
|
||||
ref/
|
||||
# Binaries
|
||||
portwardenc-amd64
|
||||
|
||||
|
||||
@ -18,6 +18,13 @@ services:
|
||||
retries: 10
|
||||
ports:
|
||||
- "15432:5432"
|
||||
nats:
|
||||
image: nats:2.9
|
||||
ports:
|
||||
- "4222:4222"
|
||||
- "8222:8222" # For monitoring
|
||||
volumes:
|
||||
- nats_data:/data
|
||||
|
||||
data-persistence-service:
|
||||
build:
|
||||
@ -38,30 +45,6 @@ services:
|
||||
# volumes:
|
||||
# - ./:/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:
|
||||
build:
|
||||
@ -71,8 +54,8 @@ services:
|
||||
working_dir: /workspace/frontend
|
||||
command: npm run dev
|
||||
environment:
|
||||
# 让 Next 的 API 路由代理到后端容器
|
||||
NEXT_PUBLIC_BACKEND_URL: http://backend:8000/api
|
||||
# 让 Next 的 API 路由代理到新的 api-gateway
|
||||
NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1
|
||||
# Prisma 直连数据库(与后端共用同一库)
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental?schema=public
|
||||
NODE_ENV: development
|
||||
@ -84,26 +67,153 @@ services:
|
||||
ports:
|
||||
- "13001:3001"
|
||||
depends_on:
|
||||
- backend
|
||||
- postgres-db
|
||||
- config-service
|
||||
- api-gateway
|
||||
|
||||
config-service:
|
||||
|
||||
api-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/config-service/Dockerfile
|
||||
container_name: fundamental-config-service
|
||||
working_dir: /workspace/services/config-service
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 7000
|
||||
context: ./services/api-gateway
|
||||
dockerfile: Dockerfile
|
||||
container_name: api-gateway
|
||||
environment:
|
||||
PROJECT_ROOT: /workspace
|
||||
volumes:
|
||||
- ./:/workspace
|
||||
SERVER_PORT: 4000
|
||||
NATS_ADDR: nats://nats:4222
|
||||
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:
|
||||
- "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:
|
||||
pgdata:
|
||||
frontend_node_modules:
|
||||
nats_data:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
|
||||
|
||||
|
||||
155
docs/architecture_module_specification.md
Normal file
155
docs/architecture_module_specification.md
Normal 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`规范为我们的微服务生态系统提供了骨架和纪律。通过强制要求所有模块实现标准的可观测性接口和消息契约,我们可以构建一个真正健壮、透明且易于管理的分布式系统。所有新服务的开发都将以此规范为起点。
|
||||
23
frontend/src/app/api/companies/[symbol]/profile/route.ts
Normal file
23
frontend/src/app/api/companies/[symbol]/profile/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
|
||||
22
frontend/src/app/api/data-requests/route.ts
Normal file
22
frontend/src/app/api/data-requests/route.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
23
frontend/src/app/api/tasks/[request_id]/route.ts
Normal file
23
frontend/src/app/api/tasks/[request_id]/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
|
||||
@ -1,66 +1,97 @@
|
||||
import useSWR from 'swr';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { BatchFinancialDataResponse, FinancialConfigResponse, AnalysisConfigResponse, TodaySnapshotResponse, RealTimeQuoteResponse } from '@/types';
|
||||
import useSWR, { SWRConfiguration } from "swr";
|
||||
import { Financials, FinancialsIdentifier } from "@/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnalysisStep, AnalysisTask } from "@/lib/execution-step-manager";
|
||||
|
||||
const fetcher = async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
const contentType = res.headers.get('Content-Type') || '';
|
||||
const text = await res.text();
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
// 尝试解析JSON
|
||||
const tryParseJson = () => {
|
||||
try { return JSON.parse(text); } catch { return null; }
|
||||
};
|
||||
// --- 新的异步任务Hooks ---
|
||||
|
||||
const data = contentType.includes('application/json') ? tryParseJson() : tryParseJson();
|
||||
// 用于触发数据获取任务
|
||||
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) {
|
||||
// 后端可能返回纯文本错误,统一抛出可读错误
|
||||
const message = data && data.detail ? data.detail : (text || `Request failed: ${res.status}`);
|
||||
throw new Error(message);
|
||||
throw new Error(`Request failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
throw new Error('无效的服务器响应(非JSON)');
|
||||
const data = await res.json();
|
||||
return data.request_id;
|
||||
} catch (e: any) {
|
||||
setError(e);
|
||||
} finally {
|
||||
setIsMutating(false);
|
||||
}
|
||||
|
||||
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 };
|
||||
return {
|
||||
trigger,
|
||||
isMutating,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateConfig(newConfig: any) {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newConfig),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
|
||||
// 用于轮询任务进度
|
||||
export function useTaskProgress(requestId: string | null, options?: SWRConfiguration) {
|
||||
const { data, error, isLoading } = useSWR(
|
||||
requestId ? `/api/tasks/${requestId}` : null,
|
||||
fetcher,
|
||||
{
|
||||
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) {
|
||||
const res = await fetch('/api/config/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config_type: type, config_data: data }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
// --- 保留的旧Hooks (用于查询最终数据) ---
|
||||
|
||||
export function useCompanyProfile(symbol?: string, market?: string) {
|
||||
const { data, error, isLoading } = useSWR(
|
||||
symbol && market ? `/api/companies/${symbol}/profile` : null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
return {
|
||||
profile: data,
|
||||
isLoading,
|
||||
isError: error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFinancialConfig() {
|
||||
return useSWR<FinancialConfigResponse>('/api/financials/config', fetcher);
|
||||
}
|
||||
// ... 这里的其他数据查询Hooks (如财务报表等) 也将遵循类似的模式,
|
||||
// 直接从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) {
|
||||
return useSWR<BatchFinancialDataResponse>(
|
||||
ts_code ? `/api/financials/cn/${encodeURIComponent(ts_code)}?years=${encodeURIComponent(String(years))}` : null,
|
||||
|
||||
4298
services/alphavantage-provider-service/Cargo.lock
generated
Normal file
4298
services/alphavantage-provider-service/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
services/alphavantage-provider-service/Cargo.toml
Normal file
45
services/alphavantage-provider-service/Cargo.toml
Normal 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"] }
|
||||
119
services/alphavantage-provider-service/src/alphavantage.rs
Normal file
119
services/alphavantage-provider-service/src/alphavantage.rs
Normal 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>,
|
||||
}
|
||||
|
||||
|
||||
43
services/alphavantage-provider-service/src/api.rs
Normal file
43
services/alphavantage-provider-service/src/api.rs
Normal 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)
|
||||
}
|
||||
60
services/alphavantage-provider-service/src/av_client.rs
Normal file
60
services/alphavantage-provider-service/src/av_client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
services/alphavantage-provider-service/src/config.rs
Normal file
20
services/alphavantage-provider-service/src/config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
40
services/alphavantage-provider-service/src/error.rs
Normal file
40
services/alphavantage-provider-service/src/error.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
46
services/alphavantage-provider-service/src/main.rs
Normal file
46
services/alphavantage-provider-service/src/main.rs
Normal 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(())
|
||||
}
|
||||
184
services/alphavantage-provider-service/src/mapping.rs
Normal file
184
services/alphavantage-provider-service/src/mapping.rs
Normal 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()),
|
||||
})
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
70
services/alphavantage-provider-service/src/persistence.rs
Normal file
70
services/alphavantage-provider-service/src/persistence.rs
Normal 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("e)
|
||||
.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(())
|
||||
}
|
||||
}
|
||||
23
services/alphavantage-provider-service/src/state.rs
Normal file
23
services/alphavantage-provider-service/src/state.rs
Normal 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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
149
services/alphavantage-provider-service/src/worker.rs
Normal file
149
services/alphavantage-provider-service/src/worker.rs
Normal 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", ¶ms_overview);
|
||||
let income_task = client.query("INCOME_STATEMENT", ¶ms_income);
|
||||
let balance_task = client.query("BALANCE_SHEET", ¶ms_balance);
|
||||
let cashflow_task = client.query("CASH_FLOW", ¶ms_cashflow);
|
||||
let quote_task = client.query("GLOBAL_QUOTE", ¶ms_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
4033
services/api-gateway/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
services/api-gateway/Cargo.toml
Normal file
38
services/api-gateway/Cargo.toml
Normal 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"
|
||||
147
services/api-gateway/src/api.rs
Normal file
147
services/api-gateway/src/api.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
19
services/api-gateway/src/config.rs
Normal file
19
services/api-gateway/src/config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
58
services/api-gateway/src/error.rs
Normal file
58
services/api-gateway/src/error.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
39
services/api-gateway/src/main.rs
Normal file
39
services/api-gateway/src/main.rs
Normal 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(())
|
||||
}
|
||||
34
services/api-gateway/src/persistence.rs
Normal file
34
services/api-gateway/src/persistence.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
27
services/api-gateway/src/state.rs
Normal file
27
services/api-gateway/src/state.rs
Normal 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
2678
services/common-contracts/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
services/common-contracts/Cargo.toml
Normal file
22
services/common-contracts/Cargo.toml
Normal 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" }
|
||||
|
||||
|
||||
89
services/common-contracts/src/dtos.rs
Normal file
89
services/common-contracts/src/dtos.rs
Normal 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>,
|
||||
}
|
||||
|
||||
|
||||
6
services/common-contracts/src/lib.rs
Normal file
6
services/common-contracts/src/lib.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub mod dtos;
|
||||
pub mod models;
|
||||
pub mod observability;
|
||||
pub mod messages;
|
||||
|
||||
|
||||
26
services/common-contracts/src/messages.rs
Normal file
26
services/common-contracts/src/messages.rs
Normal 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>,
|
||||
}
|
||||
|
||||
|
||||
89
services/common-contracts/src/models.rs
Normal file
89
services/common-contracts/src/models.rs
Normal 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>,
|
||||
}
|
||||
|
||||
|
||||
31
services/common-contracts/src/observability.rs
Normal file
31
services/common-contracts/src/observability.rs
Normal 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
1280
services/config-service-rs/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
services/config-service-rs/Cargo.toml
Normal file
18
services/config-service-rs/Cargo.toml
Normal 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"
|
||||
33
services/config-service-rs/Dockerfile
Normal file
33
services/config-service-rs/Dockerfile
Normal 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"]
|
||||
62
services/config-service-rs/src/api.rs
Normal file
62
services/config-service-rs/src/api.rs
Normal 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())
|
||||
}
|
||||
19
services/config-service-rs/src/config.rs
Normal file
19
services/config-service-rs/src/config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
18
services/config-service-rs/src/error.rs
Normal file
18
services/config-service-rs/src/error.rs
Normal 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),
|
||||
}
|
||||
27
services/config-service-rs/src/main.rs
Normal file
27
services/config-service-rs/src/main.rs
Normal 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(())
|
||||
}
|
||||
15
services/data-persistence-service/Cargo.lock
generated
15
services/data-persistence-service/Cargo.lock
generated
@ -323,6 +323,20 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "common-contracts"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"service_kit",
|
||||
"sqlx",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@ -445,6 +459,7 @@ dependencies = [
|
||||
"axum",
|
||||
"axum-embed",
|
||||
"chrono",
|
||||
"common-contracts",
|
||||
"dotenvy",
|
||||
"http-body-util",
|
||||
"rmcp 0.8.5",
|
||||
|
||||
@ -26,6 +26,7 @@ rmcp = { version = "0.8.5", features = [
|
||||
"transport-streamable-http-server",
|
||||
"transport-worker"
|
||||
] }
|
||||
common-contracts = { path = "../common-contracts" }
|
||||
|
||||
# Web framework
|
||||
axum = "0.8"
|
||||
|
||||
@ -3,3 +3,4 @@
|
||||
pub mod companies;
|
||||
pub mod market_data;
|
||||
pub mod analysis;
|
||||
pub mod system;
|
||||
|
||||
36
services/data-persistence-service/src/api/system.rs
Normal file
36
services/data-persistence-service/src/api/system.rs
Normal 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()))
|
||||
}
|
||||
|
||||
|
||||
@ -1,99 +1 @@
|
||||
use chrono::NaiveDate;
|
||||
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>,
|
||||
}
|
||||
pub use common_contracts::dtos::*;
|
||||
|
||||
@ -1,87 +1 @@
|
||||
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, // 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>,
|
||||
}
|
||||
pub use common_contracts::models::*;
|
||||
|
||||
4085
services/finnhub-provider-service/Cargo.lock
generated
Normal file
4085
services/finnhub-provider-service/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
services/finnhub-provider-service/Cargo.toml
Normal file
49
services/finnhub-provider-service/Cargo.toml
Normal 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"
|
||||
31
services/finnhub-provider-service/Dockerfile
Normal file
31
services/finnhub-provider-service/Dockerfile
Normal 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"]
|
||||
43
services/finnhub-provider-service/src/api.rs
Normal file
43
services/finnhub-provider-service/src/api.rs
Normal 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)
|
||||
}
|
||||
21
services/finnhub-provider-service/src/config.rs
Normal file
21
services/finnhub-provider-service/src/config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
40
services/finnhub-provider-service/src/error.rs
Normal file
40
services/finnhub-provider-service/src/error.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
55
services/finnhub-provider-service/src/fh_client.rs
Normal file
55
services/finnhub-provider-service/src/fh_client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
104
services/finnhub-provider-service/src/finnhub.rs
Normal file
104
services/finnhub-provider-service/src/finnhub.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
services/finnhub-provider-service/src/main.rs
Normal file
47
services/finnhub-provider-service/src/main.rs
Normal 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(())
|
||||
}
|
||||
111
services/finnhub-provider-service/src/mapping.rs
Normal file
111
services/finnhub-provider-service/src/mapping.rs
Normal 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
|
||||
}
|
||||
95
services/finnhub-provider-service/src/message_consumer.rs
Normal file
95
services/finnhub-provider-service/src/message_consumer.rs
Normal 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(())
|
||||
}
|
||||
70
services/finnhub-provider-service/src/persistence.rs
Normal file
70
services/finnhub-provider-service/src/persistence.rs
Normal 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("e)
|
||||
.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(())
|
||||
}
|
||||
}
|
||||
32
services/finnhub-provider-service/src/state.rs
Normal file
32
services/finnhub-provider-service/src/state.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
83
services/finnhub-provider-service/src/worker.rs
Normal file
83
services/finnhub-provider-service/src/worker.rs
Normal 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(())
|
||||
}
|
||||
|
||||
4180
services/report-generator-service/Cargo.lock
generated
Normal file
4180
services/report-generator-service/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
services/report-generator-service/Cargo.toml
Normal file
43
services/report-generator-service/Cargo.toml
Normal 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"
|
||||
18
services/report-generator-service/Dockerfile
Normal file
18
services/report-generator-service/Dockerfile
Normal 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"]
|
||||
43
services/report-generator-service/src/api.rs
Normal file
43
services/report-generator-service/src/api.rs
Normal 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)
|
||||
}
|
||||
22
services/report-generator-service/src/config.rs
Normal file
22
services/report-generator-service/src/config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
24
services/report-generator-service/src/error.rs
Normal file
24
services/report-generator-service/src/error.rs
Normal 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),
|
||||
}
|
||||
74
services/report-generator-service/src/llm_client.rs
Normal file
74
services/report-generator-service/src/llm_client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
54
services/report-generator-service/src/main.rs
Normal file
54
services/report-generator-service/src/main.rs
Normal 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(())
|
||||
}
|
||||
40
services/report-generator-service/src/message_consumer.rs
Normal file
40
services/report-generator-service/src/message_consumer.rs
Normal 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(())
|
||||
}
|
||||
101
services/report-generator-service/src/persistence.rs
Normal file
101
services/report-generator-service/src/persistence.rs
Normal 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("e)
|
||||
.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(())
|
||||
}
|
||||
}
|
||||
34
services/report-generator-service/src/state.rs
Normal file
34
services/report-generator-service/src/state.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
19
services/report-generator-service/src/templates.rs
Normal file
19
services/report-generator-service/src/templates.rs
Normal 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)
|
||||
}
|
||||
70
services/report-generator-service/src/worker.rs
Normal file
70
services/report-generator-service/src/worker.rs
Normal 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))
|
||||
}
|
||||
@ -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.
|
||||
@ -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"]
|
||||
47
services/tuhsare-provider-service/src/tushare.rs
Normal file
47
services/tuhsare-provider-service/src/tushare.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
4110
services/tushare-provider-service/Cargo.lock
generated
Normal file
4110
services/tushare-provider-service/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
services/tushare-provider-service/Cargo.toml
Normal file
33
services/tushare-provider-service/Cargo.toml
Normal 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"
|
||||
31
services/tushare-provider-service/Dockerfile
Normal file
31
services/tushare-provider-service/Dockerfile
Normal 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"]
|
||||
23
services/tushare-provider-service/src/api.rs
Normal file
23
services/tushare-provider-service/src/api.rs
Normal 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)
|
||||
}
|
||||
19
services/tushare-provider-service/src/config.rs
Normal file
19
services/tushare-provider-service/src/config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
27
services/tushare-provider-service/src/error.rs
Normal file
27
services/tushare-provider-service/src/error.rs
Normal 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>;
|
||||
47
services/tushare-provider-service/src/main.rs
Normal file
47
services/tushare-provider-service/src/main.rs
Normal 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(())
|
||||
}
|
||||
273
services/tushare-provider-service/src/mapping.rs
Normal file
273
services/tushare-provider-service/src/mapping.rs
Normal 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(¤t_year_str)).cloned();
|
||||
process_special_data(&mut series, &raw_data, ¤t_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(¤t_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(¤t_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 映射辅助)
|
||||
115
services/tushare-provider-service/src/message_consumer.rs
Normal file
115
services/tushare-provider-service/src/message_consumer.rs
Normal 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(())
|
||||
}
|
||||
70
services/tushare-provider-service/src/persistence.rs
Normal file
70
services/tushare-provider-service/src/persistence.rs
Normal 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("e)
|
||||
.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(())
|
||||
}
|
||||
}
|
||||
31
services/tushare-provider-service/src/state.rs
Normal file
31
services/tushare-provider-service/src/state.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
99
services/tushare-provider-service/src/ts_client.rs
Normal file
99
services/tushare-provider-service/src/ts_client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
320
services/tushare-provider-service/src/tushare.rs
Normal file
320
services/tushare-provider-service/src/tushare.rs
Normal 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>,
|
||||
}
|
||||
|
||||
|
||||
162
services/tushare-provider-service/src/worker.rs
Normal file
162
services/tushare-provider-service/src/worker.rs
Normal 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(())
|
||||
}
|
||||
4072
services/yfinance-provider-service/Cargo.lock
generated
Normal file
4072
services/yfinance-provider-service/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
services/yfinance-provider-service/Cargo.toml
Normal file
45
services/yfinance-provider-service/Cargo.toml
Normal 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"
|
||||
31
services/yfinance-provider-service/Dockerfile
Normal file
31
services/yfinance-provider-service/Dockerfile
Normal 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"]
|
||||
43
services/yfinance-provider-service/src/api.rs
Normal file
43
services/yfinance-provider-service/src/api.rs
Normal 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)
|
||||
}
|
||||
19
services/yfinance-provider-service/src/config.rs
Normal file
19
services/yfinance-provider-service/src/config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
40
services/yfinance-provider-service/src/error.rs
Normal file
40
services/yfinance-provider-service/src/error.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
46
services/yfinance-provider-service/src/main.rs
Normal file
46
services/yfinance-provider-service/src/main.rs
Normal 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(())
|
||||
}
|
||||
133
services/yfinance-provider-service/src/mapping.rs
Normal file
133
services/yfinance-provider-service/src/mapping.rs
Normal 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
Loading…
Reference in New Issue
Block a user