perf: optimize system concurrency, I/O stability and fix WebSocket disconnects

This commit is contained in:
2026-04-07 13:58:49 +08:00
parent 62c7341cf6
commit 11849208ed
21 changed files with 357 additions and 215 deletions

View File

@@ -12,7 +12,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
from datetime import UTC, datetime from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Any, Callable, Dict, Iterable, List, Optional, Set from typing import Any, Callable, Dict, Iterable, List, Optional, Set
@@ -78,7 +78,7 @@ class ApprovalRecord:
self.session_id = session_id self.session_id = session_id
self.status = ApprovalStatus.PENDING self.status = ApprovalStatus.PENDING
self.findings = findings or [] self.findings = findings or []
self.created_at = datetime.now(UTC) self.created_at = datetime.now(timezone.utc)
self.resolved_at: Optional[datetime] = None self.resolved_at: Optional[datetime] = None
self.resolved_by: Optional[str] = None self.resolved_by: Optional[str] = None
self.metadata: Dict[str, Any] = {} self.metadata: Dict[str, Any] = {}
@@ -163,7 +163,7 @@ class ToolGuardStore:
return record return record
record.status = status record.status = status
record.resolved_at = datetime.now(UTC) record.resolved_at = datetime.now(timezone.utc)
record.resolved_by = resolved_by record.resolved_by = resolved_by
if notify_request and record.pending_request: if notify_request and record.pending_request:
if status == ApprovalStatus.APPROVED: if status == ApprovalStatus.APPROVED:

View File

@@ -7,7 +7,7 @@ Provides REST API endpoints for tool guard operations.
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from datetime import UTC, datetime from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -146,7 +146,7 @@ async def check_tool_call(
if request.tool_name in SAFE_TOOLS: if request.tool_name in SAFE_TOOLS:
record.status = ApprovalStatus.APPROVED record.status = ApprovalStatus.APPROVED
record.resolved_at = datetime.now(UTC) record.resolved_at = datetime.now(timezone.utc)
record.resolved_by = "system" record.resolved_by = "system"
STORE.set_status( STORE.set_status(
record.approval_id, record.approval_id,

View File

@@ -81,7 +81,12 @@ async def proxy_ws(ws: WebSocket):
await ws.accept() await ws.accept()
upstream = None upstream = None
try: try:
upstream = await websockets.asyncio.client.connect(gateway_url) upstream = await websockets.asyncio.client.connect(
gateway_url,
ping_interval=20,
ping_timeout=120,
max_size=10 * 1024 * 1024, # 10MB
)
async def client_to_upstream(): async def client_to_upstream():
try: try:

View File

@@ -28,11 +28,11 @@ def create_app() -> FastAPI:
add_cors_middleware(app) add_cors_middleware(app)
@app.get("/health") @app.get("/health")
async def health_check() -> dict[str, str]: def health_check() -> dict[str, str]:
return {"status": "healthy", "service": "news-service"} return {"status": "healthy", "service": "news-service"}
@app.get("/api/enriched-news") @app.get("/api/enriched-news")
async def api_get_enriched_news( def api_get_enriched_news(
ticker: str = Query(..., min_length=1), ticker: str = Query(..., min_length=1),
start_date: str | None = Query(None), start_date: str | None = Query(None),
end_date: str | None = Query(None), end_date: str | None = Query(None),
@@ -49,7 +49,7 @@ def create_app() -> FastAPI:
) )
@app.get("/api/news-for-date") @app.get("/api/news-for-date")
async def api_get_news_for_date( def api_get_news_for_date(
ticker: str = Query(..., min_length=1), ticker: str = Query(..., min_length=1),
date: str = Query(...), date: str = Query(...),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
@@ -64,7 +64,7 @@ def create_app() -> FastAPI:
) )
@app.get("/api/news-timeline") @app.get("/api/news-timeline")
async def api_get_news_timeline( def api_get_news_timeline(
ticker: str = Query(..., min_length=1), ticker: str = Query(..., min_length=1),
start_date: str = Query(...), start_date: str = Query(...),
end_date: str = Query(...), end_date: str = Query(...),
@@ -79,7 +79,7 @@ def create_app() -> FastAPI:
) )
@app.get("/api/categories") @app.get("/api/categories")
async def api_get_categories( def api_get_categories(
ticker: str = Query(..., min_length=1), ticker: str = Query(..., min_length=1),
start_date: str | None = Query(None), start_date: str | None = Query(None),
end_date: str | None = Query(None), end_date: str | None = Query(None),
@@ -96,7 +96,7 @@ def create_app() -> FastAPI:
) )
@app.get("/api/similar-days") @app.get("/api/similar-days")
async def api_get_similar_days( def api_get_similar_days(
ticker: str = Query(..., min_length=1), ticker: str = Query(..., min_length=1),
date: str = Query(...), date: str = Query(...),
n_similar: int = Query(5, ge=1, le=20), n_similar: int = Query(5, ge=1, le=20),
@@ -111,7 +111,7 @@ def create_app() -> FastAPI:
) )
@app.get("/api/stories/{ticker}") @app.get("/api/stories/{ticker}")
async def api_get_story( def api_get_story(
ticker: str, ticker: str,
as_of_date: str = Query(...), as_of_date: str = Query(...),
store: MarketStore = Depends(get_market_store), store: MarketStore = Depends(get_market_store),
@@ -124,7 +124,7 @@ def create_app() -> FastAPI:
) )
@app.get("/api/range-explain") @app.get("/api/range-explain")
async def api_get_range_explain( def api_get_range_explain(
ticker: str = Query(..., min_length=1), ticker: str = Query(..., min_length=1),
start_date: str = Query(...), start_date: str = Query(...),
end_date: str = Query(...), end_date: str = Query(...),

View File

@@ -29,12 +29,12 @@ def create_app() -> FastAPI:
add_cors_middleware(app) add_cors_middleware(app)
@app.get("/health") @app.get("/health")
async def health_check() -> dict[str, str]: def health_check() -> dict[str, str]:
"""Health check endpoint.""" """Health check endpoint."""
return {"status": "healthy", "service": "trading-service"} return {"status": "healthy", "service": "trading-service"}
@app.get("/api/prices", response_model=PriceResponse) @app.get("/api/prices", response_model=PriceResponse)
async def api_get_prices( def api_get_prices(
ticker: str = Query(..., min_length=1), ticker: str = Query(..., min_length=1),
start_date: str = Query(...), start_date: str = Query(...),
end_date: str = Query(...), end_date: str = Query(...),
@@ -47,7 +47,7 @@ def create_app() -> FastAPI:
return PriceResponse(ticker=payload["ticker"], prices=payload["prices"]) return PriceResponse(ticker=payload["ticker"], prices=payload["prices"])
@app.get("/api/financials", response_model=FinancialMetricsResponse) @app.get("/api/financials", response_model=FinancialMetricsResponse)
async def api_get_financials( def api_get_financials(
ticker: str = Query(..., min_length=1), ticker: str = Query(..., min_length=1),
end_date: str = Query(...), end_date: str = Query(...),
period: str = Query("ttm"), period: str = Query("ttm"),
@@ -62,7 +62,7 @@ def create_app() -> FastAPI:
return FinancialMetricsResponse(financial_metrics=payload["financial_metrics"]) return FinancialMetricsResponse(financial_metrics=payload["financial_metrics"])
@app.get("/api/news", response_model=CompanyNewsResponse) @app.get("/api/news", response_model=CompanyNewsResponse)
async def api_get_news( def api_get_news(
ticker: str = Query(..., min_length=1), ticker: str = Query(..., min_length=1),
end_date: str = Query(...), end_date: str = Query(...),
start_date: str | None = Query(None), start_date: str | None = Query(None),
@@ -77,7 +77,7 @@ def create_app() -> FastAPI:
return CompanyNewsResponse(news=payload["news"]) return CompanyNewsResponse(news=payload["news"])
@app.get("/api/insider-trades", response_model=InsiderTradeResponse) @app.get("/api/insider-trades", response_model=InsiderTradeResponse)
async def api_get_insider_trades( def api_get_insider_trades(
ticker: str = Query(..., min_length=1), ticker: str = Query(..., min_length=1),
end_date: str = Query(...), end_date: str = Query(...),
start_date: str | None = Query(None), start_date: str | None = Query(None),
@@ -92,12 +92,12 @@ def create_app() -> FastAPI:
return InsiderTradeResponse(insider_trades=payload["insider_trades"]) return InsiderTradeResponse(insider_trades=payload["insider_trades"])
@app.get("/api/market/status") @app.get("/api/market/status")
async def api_get_market_status() -> dict[str, Any]: def api_get_market_status() -> dict[str, Any]:
"""Return current market status using the existing market service logic.""" """Return current market status using the existing market service logic."""
return trading_domain.get_market_status_payload() return trading_domain.get_market_status_payload()
@app.get("/api/market-cap") @app.get("/api/market-cap")
async def api_get_market_cap( def api_get_market_cap(
ticker: str = Query(..., min_length=1), ticker: str = Query(..., min_length=1),
end_date: str = Query(...), end_date: str = Query(...),
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -108,7 +108,7 @@ def create_app() -> FastAPI:
) )
@app.get("/api/line-items", response_model=LineItemResponse) @app.get("/api/line-items", response_model=LineItemResponse)
async def api_get_line_items( def api_get_line_items(
ticker: str = Query(..., min_length=1), ticker: str = Query(..., min_length=1),
line_items: list[str] = Query(...), line_items: list[str] = Query(...),
end_date: str = Query(...), end_date: str = Query(...),

View File

@@ -144,7 +144,7 @@ class TradingPipeline:
self._team_controller = DynamicTeamController( self._team_controller = DynamicTeamController(
create_callback=self._create_runtime_analyst, create_callback=self._create_runtime_analyst,
remove_callback=self._remove_runtime_analyst, remove_callback=self._remove_runtime_analyst,
get_analysts_callback=self._all_analysts, get_analysts_callback=lambda: self._all_analysts() + [self.risk_manager, self.pm],
) )
set_controller(self._team_controller) set_controller(self._team_controller)

View File

@@ -123,7 +123,11 @@ class StateSync:
# Persist to feed_history # Persist to feed_history
if persist: if persist:
self.storage.add_feed_message(self._state, event) self.storage.add_feed_message(self._state, event)
self.save_state() # Make persistence non-blocking to keep event loop snappy
if asyncio.get_event_loop().is_running():
asyncio.create_task(asyncio.to_thread(self.save_state))
else:
self.save_state()
# Broadcast to frontend # Broadcast to frontend
if self._broadcast_fn: if self._broadcast_fn:

View File

@@ -190,8 +190,9 @@ class MarketStore:
name: str | None = None, name: str | None = None,
sector: str | None = None, sector: str | None = None,
is_active: bool = True, is_active: bool = True,
) -> None: ) -> int:
timestamp = _utc_timestamp() timestamp = _utc_timestamp()
count = 0
with self._connect() as conn: with self._connect() as conn:
conn.execute( conn.execute(
""" """
@@ -206,6 +207,8 @@ class MarketStore:
""", """,
(symbol, name, sector, 1 if is_active else 0, timestamp, timestamp), (symbol, name, sector, 1 if is_active else 0, timestamp, timestamp),
) )
count += 1
return count
def update_fetch_watermark( def update_fetch_watermark(
self, self,
@@ -213,8 +216,9 @@ class MarketStore:
symbol: str, symbol: str,
price_date: str | None = None, price_date: str | None = None,
news_date: str | None = None, news_date: str | None = None,
) -> None: ) -> int:
timestamp = _utc_timestamp() timestamp = _utc_timestamp()
count = 0
with self._connect() as conn: with self._connect() as conn:
conn.execute( conn.execute(
""" """
@@ -227,6 +231,8 @@ class MarketStore:
""", """,
(symbol, timestamp, timestamp, price_date, news_date), (symbol, timestamp, timestamp, price_date, news_date),
) )
count += 1
return count
def get_ticker_watermarks(self, symbol: str) -> dict[str, Any]: def get_ticker_watermarks(self, symbol: str) -> dict[str, Any]:
with self._connect() as conn: with self._connect() as conn:
@@ -263,6 +269,8 @@ class MarketStore:
count = 0 count = 0
with self._connect() as conn: with self._connect() as conn:
for row in rows: for row in rows:
if not row.get("date"):
continue
conn.execute( conn.execute(
""" """
INSERT INTO ohlc INSERT INTO ohlc
@@ -341,6 +349,7 @@ class MarketStore:
timestamp, timestamp,
), ),
) )
count += 1
for ticker in tickers: for ticker in tickers:
conn.execute( conn.execute(
""" """
@@ -349,7 +358,6 @@ class MarketStore:
""", """,
(news_id, str(ticker).strip().upper()), (news_id, str(ticker).strip().upper()),
) )
count += 1
return count return count
def get_news_without_trade_date(self, symbol: str | None = None, *, limit: int = 5000) -> list[dict[str, Any]]: def get_news_without_trade_date(self, symbol: str | None = None, *, limit: int = 5000) -> list[dict[str, Any]]:
@@ -928,8 +936,9 @@ class MarketStore:
as_of_date: str, as_of_date: str,
content: str, content: str,
source: str = "local", source: str = "local",
) -> None: ) -> int:
timestamp = _utc_timestamp() timestamp = _utc_timestamp()
count = 0
with self._connect() as conn: with self._connect() as conn:
conn.execute( conn.execute(
""" """
@@ -943,6 +952,8 @@ class MarketStore:
""", """,
(symbol, as_of_date, content, source, timestamp, timestamp), (symbol, as_of_date, content, source, timestamp, timestamp),
) )
count += 1
return count
def delete_story_cache( def delete_story_cache(
self, self,
@@ -1002,8 +1013,9 @@ class MarketStore:
target_date: str, target_date: str,
payload: dict[str, Any], payload: dict[str, Any],
source: str = "local", source: str = "local",
) -> None: ) -> int:
timestamp = _utc_timestamp() timestamp = _utc_timestamp()
count = 0
with self._connect() as conn: with self._connect() as conn:
conn.execute( conn.execute(
""" """
@@ -1017,6 +1029,8 @@ class MarketStore:
""", """,
(symbol, target_date, _json_dumps(payload), source, timestamp, timestamp), (symbol, target_date, _json_dumps(payload), source, timestamp, timestamp),
) )
count += 1
return count
def delete_similar_day_cache( def delete_similar_day_cache(
self, self,

View File

@@ -444,6 +444,16 @@ def create_model(
""" """
provider = canonicalize_model_provider(provider) provider = canonicalize_model_provider(provider)
# If provider is default OPENAI but model name looks like deepseek,
# check if we should switch to DASHSCOPE.
if provider == "OPENAI" and "deepseek" in model_name.lower() and os.getenv("DASHSCOPE_API_KEY"):
provider = "DASHSCOPE"
# Intelligent routing: if it's a DeepSeek model and we have DashScope credentials,
# prefer using DashScopeChatModel over OpenAIChatModel.
if provider == "DEEPSEEK" and os.getenv("DASHSCOPE_API_KEY"):
provider = "DASHSCOPE"
model_class = PROVIDER_MODEL_MAP.get(provider) model_class = PROVIDER_MODEL_MAP.get(provider)
if model_class is None: if model_class is None:
raise ValueError(f"Unsupported provider: {provider}") raise ValueError(f"Unsupported provider: {provider}")

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, UTC from datetime import datetime, timezone
from typing import Any, Dict from typing import Any, Dict
@@ -11,12 +11,12 @@ class AgentRuntimeState:
display_name: str | None = None display_name: str | None = None
status: str = "idle" status: str = "idle"
last_session: str | None = None last_session: str | None = None
last_updated: datetime = field(default_factory=lambda: datetime.now(UTC)) last_updated: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def update(self, status: str, session_key: str | None = None) -> None: def update(self, status: str, session_key: str | None = None) -> None:
self.status = status self.status = status
self.last_session = session_key self.last_session = session_key
self.last_updated = datetime.now(UTC) self.last_updated = datetime.now(timezone.utc)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { return {

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from datetime import datetime, UTC from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -93,7 +93,7 @@ class TradingRuntimeManager:
def log_event(self, event: str, details: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: def log_event(self, event: str, details: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
entry = { entry = {
"timestamp": datetime.now(UTC).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
"event": event, "event": event,
"details": details or {}, "details": details or {},
"session": self.current_session_key, "session": self.current_session_key,
@@ -120,7 +120,7 @@ class TradingRuntimeManager:
def register_pending_approval(self, approval_id: str, payload: Dict[str, Any]) -> None: def register_pending_approval(self, approval_id: str, payload: Dict[str, Any]) -> None:
payload.setdefault("status", "pending") payload.setdefault("status", "pending")
payload.setdefault("created_at", datetime.now(UTC).isoformat()) payload.setdefault("created_at", datetime.now(timezone.utc).isoformat())
self.pending_approvals[approval_id] = payload self.pending_approvals[approval_id] = payload
self._persist_snapshot() self._persist_snapshot()
@@ -149,7 +149,7 @@ class TradingRuntimeManager:
if not entry: if not entry:
return return
entry["status"] = status entry["status"] = status
entry["resolved_at"] = datetime.now(UTC).isoformat() entry["resolved_at"] = datetime.now(timezone.utc).isoformat()
entry["resolved_by"] = resolved_by entry["resolved_by"] = resolved_by
self._persist_snapshot() self._persist_snapshot()

View File

@@ -148,8 +148,9 @@ class Gateway:
self.handle_client, self.handle_client,
host, host,
port, port,
ping_interval=30, ping_interval=20,
ping_timeout=60, ping_timeout=120,
max_size=10 * 1024 * 1024, # 10MB
) )
logger.info(f"WebSocket server ready: ws://{host}:{port}") logger.info(f"WebSocket server ready: ws://{host}:{port}")
@@ -833,12 +834,18 @@ class Gateway:
if not self.connected_clients: if not self.connected_clients:
return return
message_json = json.dumps(message, ensure_ascii=False, default=str) # Offload potentially heavy JSON serialization to thread
message_json = await asyncio.to_thread(
json.dumps, message, ensure_ascii=False, default=str
)
async with self.lock: async with self.lock:
# Filter only active clients to minimize unnecessary send attempts
# In websockets v13+, we must check state.name == 'OPEN'
active_clients = [c for c in self.connected_clients if c.state.name == 'OPEN']
tasks = [ tasks = [
self._send_to_client(client, message_json) self._send_to_client(client, message_json)
for client in self.connected_clients.copy() for client in active_clients
] ]
if tasks: if tasks:
@@ -849,9 +856,14 @@ class Gateway:
client: ServerConnection, client: ServerConnection,
message: str, message: str,
): ):
if client.state.name != 'OPEN':
async with self.lock:
self.connected_clients.discard(client)
return
try: try:
await client.send(message) await client.send(message)
except websockets.ConnectionClosed: except (websockets.ConnectionClosed, Exception):
async with self.lock: async with self.lock:
self.connected_clients.discard(client) self.connected_clients.discard(client)

View File

@@ -253,7 +253,8 @@ async def finalize_cycle(gateway: Any, date: str) -> None:
async def get_market_caps(gateway: Any, tickers: list[str], date: str) -> dict[str, float]: async def get_market_caps(gateway: Any, tickers: list[str], date: str) -> dict[str, float]:
market_caps: dict[str, float] = {} market_caps: dict[str, float] = {}
for ticker in tickers:
async def _get_one(ticker: str):
try: try:
market_cap = None market_cap = None
response = await gateway._call_trading_service( response = await gateway._call_trading_service(
@@ -263,12 +264,21 @@ async def get_market_caps(gateway: Any, tickers: list[str], date: str) -> dict[s
if response is not None: if response is not None:
market_cap = response.get("market_cap") market_cap = response.get("market_cap")
if market_cap is None: if market_cap is None:
payload = trading_domain.get_market_cap_payload(ticker=ticker, end_date=date) payload = await asyncio.to_thread(
trading_domain.get_market_cap_payload,
ticker=ticker,
end_date=date,
)
market_cap = payload.get("market_cap") market_cap = payload.get("market_cap")
market_caps[ticker] = market_cap if market_cap else 1e9 return ticker, (market_cap if market_cap else 1e9)
except Exception as exc: except Exception as exc:
logger.warning("Failed to get market cap for %s, using default 1e9: %s", ticker, exc) logger.warning("Failed to get market cap for %s, using default 1e9: %s", ticker, exc)
market_caps[ticker] = 1e9 return ticker, 1e9
tasks = [_get_one(ticker) for ticker in tickers]
results = await asyncio.gather(*tasks)
for ticker, mc in results:
market_caps[ticker] = mc
return market_caps return market_caps

View File

@@ -517,111 +517,129 @@ async def handle_get_stock_similar_days(gateway: Any, websocket: Any, data: dict
async def handle_get_stock_technical_indicators(gateway: Any, websocket: Any, data: dict[str, Any]) -> None: async def handle_get_stock_technical_indicators(gateway: Any, websocket: Any, data: dict[str, Any]) -> None:
ticker = normalize_symbol(data.get("ticker", "")) ticker = normalize_symbol(data.get("ticker", ""))
if not ticker: if not ticker:
await websocket.send(json.dumps({ await websocket.send(json.dumps({
"type": "stock_technical_indicators_loaded", "type": "stock_technical_indicators_loaded",
"ticker": ticker, "ticker": ticker,
"indicators": None, "indicators": None,
"error": "ticker is required", "error": "ticker is required",
}, ensure_ascii=False)) }, ensure_ascii=False))
return return
try: try:
end_date = datetime.now() end_date = datetime.now()
start_date = end_date - timedelta(days=250) # Reduced from 250 to 150 days to lower CPU/memory pressure while still supporting MA200 (approx 140 trading days)
start_date = end_date - timedelta(days=150)
prices = None prices = None
response = await gateway._call_trading_service( response = await gateway._call_trading_service(
"get_prices", "get_prices",
lambda client: client.get_prices( lambda client: client.get_prices(
ticker=ticker, ticker=ticker,
start_date=start_date.strftime("%Y-%m-%d"), start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"), end_date=end_date.strftime("%Y-%m-%d"),
), ),
) )
if response is not None: if response is not None:
prices = response.prices prices = response.prices
if prices is None: if prices is None:
payload = trading_domain.get_prices_payload( # Offload domain logic to thread
ticker=ticker, payload = await asyncio.to_thread(
start_date=start_date.strftime("%Y-%m-%d"), trading_domain.get_prices_payload,
end_date=end_date.strftime("%Y-%m-%d"), ticker=ticker,
) start_date=start_date.strftime("%Y-%m-%d"),
prices = payload.get("prices") or [] end_date=end_date.strftime("%Y-%m-%d"),
)
prices = payload.get("prices") or []
if not prices or len(prices) < 20: if not prices or len(prices) < 20:
await websocket.send(json.dumps({ await websocket.send(json.dumps({
"type": "stock_technical_indicators_loaded", "type": "stock_technical_indicators_loaded",
"ticker": ticker, "ticker": ticker,
"indicators": None, "indicators": None,
"error": "Insufficient price data", "error": "Insufficient price data",
}, ensure_ascii=False)) }, ensure_ascii=False))
return return
df = prices_to_df(prices) def _calc():
signal = gateway._technical_analyzer.analyze(ticker, df) df = prices_to_df(prices)
signal = gateway._technical_analyzer.analyze(ticker, df)
df_sorted = df.sort_values("time").reset_index(drop=True)
df_sorted["returns"] = df_sorted["close"].pct_change()
v10 = float(df_sorted["returns"].tail(10).std() * (252**0.5) * 100) if len(df_sorted) >= 10 else None
v20 = float(df_sorted["returns"].tail(20).std() * (252**0.5) * 100) if len(df_sorted) >= 20 else None
v60 = float(df_sorted["returns"].tail(60).std() * (252**0.5) * 100) if len(df_sorted) >= 60 else None
df_sorted = df.sort_values("time").reset_index(drop=True) ma_dist = {}
df_sorted["returns"] = df_sorted["close"].pct_change() for ma_key in ["ma5", "ma10", "ma20", "ma50", "ma200"]:
vol_10 = float(df_sorted["returns"].tail(10).std() * (252**0.5) * 100) if len(df_sorted) >= 10 else None ma_val = getattr(signal, ma_key, None)
vol_20 = float(df_sorted["returns"].tail(20).std() * (252**0.5) * 100) if len(df_sorted) >= 20 else None ma_dist[ma_key] = ((signal.current_price - ma_val) / ma_val) * 100 if ma_val and ma_val > 0 else None
vol_60 = float(df_sorted["returns"].tail(60).std() * (252**0.5) * 100) if len(df_sorted) >= 60 else None
ma_distance = {}
for ma_key in ["ma5", "ma10", "ma20", "ma50", "ma200"]:
ma_value = getattr(signal, ma_key, None)
ma_distance[ma_key] = ((signal.current_price - ma_value) / ma_value) * 100 if ma_value and ma_value > 0 else None
indicators = { return {
"ticker": ticker, "ticker": ticker,
"current_price": signal.current_price, "current_price": signal.current_price,
"ma": { "ma": {
"ma5": signal.ma5, "ma5": signal.ma5,
"ma10": signal.ma10, "ma10": signal.ma10,
"ma20": signal.ma20, "ma20": signal.ma20,
"ma50": signal.ma50, "ma50": signal.ma50,
"ma200": signal.ma200, "ma200": signal.ma200,
"distance": ma_distance, "distance": ma_dist,
}, },
"rsi": { "rsi": {
"rsi14": signal.rsi14, "rsi14": signal.rsi14,
"status": "oversold" if signal.rsi14 < 30 else "overbought" if signal.rsi14 > 70 else "neutral", "status": "oversold" if signal.rsi14 < 30 else "overbought" if signal.rsi14 > 70 else "neutral",
}, },
"macd": { "macd": {
"macd": signal.macd, "macd": signal.macd,
"signal": signal.macd_signal, "signal": signal.macd_signal,
"histogram": signal.macd - signal.macd_signal, "histogram": signal.macd - signal.macd_signal,
}, },
"bollinger": { "bollinger": {
"upper": signal.bollinger_upper, "upper": signal.bollinger_upper,
"mid": signal.bollinger_mid, "mid": signal.bollinger_mid,
"lower": signal.bollinger_lower, "lower": signal.bollinger_lower,
}, },
"volatility": { "volatility": {
"vol_10d": vol_10, "vol_10d": v10,
"vol_20d": vol_20, "vol_20d": v20,
"vol_60d": vol_60, "vol_60d": v60,
"annualized": signal.annualized_volatility_pct, "annualized": signal.annualized_volatility_pct,
"risk_level": signal.risk_level, "risk_level": signal.risk_level,
}, },
"trend": signal.trend, "trend": signal.trend,
"mean_reversion": signal.mean_reversion_signal, "mean_reversion": signal.mean_reversion_signal,
} }
await websocket.send(json.dumps({ # Use a semaphore to prevent too many concurrent CPU-intensive calculations
"type": "stock_technical_indicators_loaded", # which can block the event loop heartbeats.
"ticker": ticker, if not hasattr(gateway, "_calc_sem"):
"indicators": indicators, gateway._calc_sem = asyncio.Semaphore(3)
}, ensure_ascii=False, default=str))
except Exception as exc: async with gateway._calc_sem:
logger.exception("Error getting technical indicators for %s", ticker) indicators = await asyncio.to_thread(_calc)
await websocket.send(json.dumps({
"type": "stock_technical_indicators_loaded", # Also offload JSON serialization to thread to avoid blocking main loop
"ticker": ticker, msg = await asyncio.to_thread(json.dumps, {
"indicators": None, "type": "stock_technical_indicators_loaded",
"error": str(exc), "ticker": ticker,
}, ensure_ascii=False)) "indicators": indicators,
}, ensure_ascii=False, default=str)
if websocket.state.name == 'OPEN':
await websocket.send(msg)
else:
logger.warning("Websocket closed for %s, skipping indicator send", ticker)
except Exception as exc:
logger.exception("Error getting technical indicators for %s", ticker)
await websocket.send(json.dumps({
"type": "stock_technical_indicators_loaded",
"ticker": ticker,
"indicators": None,
"error": str(exc),
}, ensure_ascii=False))
async def handle_run_stock_enrich(gateway: Any, websocket: Any, data: dict[str, Any]) -> None: async def handle_run_stock_enrich(gateway: Any, websocket: Any, data: dict[str, Any]) -> None:

View File

@@ -7,6 +7,7 @@ Handles reading/writing dashboard JSON files and portfolio state
import json import json
import logging import logging
import os import os
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -950,11 +951,14 @@ class StorageService:
def save_server_state(self, state: Dict[str, Any]): def save_server_state(self, state: Dict[str, Any]):
""" """
Save server state to file Save server state to file with rate-limiting to avoid I/O storms.
Args:
state: Server state dictionary
""" """
now = time.time()
# Ensure at least 2 seconds between physical disk writes
if hasattr(self, "_last_save_time") and (now - self._last_save_time) < 2.0:
return
self._last_save_time = now
state_to_save = { state_to_save = {
**state, **state,
"last_saved": datetime.now().isoformat(), "last_saved": datetime.now().isoformat(),
@@ -970,14 +974,17 @@ class StorageService:
if "trades" in state_to_save: if "trades" in state_to_save:
state_to_save["trades"] = state_to_save["trades"][:100] state_to_save["trades"] = state_to_save["trades"][:100]
with open(self.server_state_file, "w", encoding="utf-8") as f: try:
json.dump( with open(self.server_state_file, "w", encoding="utf-8") as f:
state_to_save, # Removed indent=2 to minimize file size and serialization overhead
f, json.dump(
ensure_ascii=False, state_to_save,
indent=2, f,
default=str, ensure_ascii=False,
) default=str,
)
except Exception as e:
logger.error(f"Failed to save server state: {e}")
logger.debug(f"Server state saved to: {self.server_state_file}") logger.debug(f"Server state saved to: {self.server_state_file}")

View File

@@ -83,7 +83,7 @@ Before using the production scripts, ensure the runtime environment has:
- a usable Python environment - a usable Python environment
- backend dependencies installed from the checked-in Python package metadata in `pyproject.toml` - backend dependencies installed from the checked-in Python package metadata in `pyproject.toml`
- the package installed with `pip install -e .` or `uv pip install -e .` - the package installed with `pip install -e .` or `uv pip install -e .`
- frontend dependencies installed with `npm ci` - frontend dependencies installed with `npm install`
- repo dependencies installed - repo dependencies installed
- required market/model API keys - required market/model API keys
- any desired `TICKERS` override - any desired `TICKERS` override
@@ -94,7 +94,7 @@ Recommended production install sequence:
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -e . pip install -e .
cd frontend && npm ci && npm run build && cd .. cd frontend && npm install && npm run build && cd ..
``` ```
## Skill Sandbox Configuration ## Skill Sandbox Configuration

164
deploy/install-production.sh Normal file → Executable file
View File

@@ -166,7 +166,7 @@ KillMode=mixed
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
ProtectSystem=full ProtectSystem=full
ProtectHome=true ProtectHome=false
LimitNOFILE=65535 LimitNOFILE=65535
TasksMax=4096 TasksMax=4096
MemoryMax=${memory_max} MemoryMax=${memory_max}
@@ -477,7 +477,17 @@ main() {
SERVICE_GROUP="${SERVICE_GROUP:-$(ask_required 'systemd 运行用户组' "$(id -gn)")}" SERVICE_GROUP="${SERVICE_GROUP:-$(ask_required 'systemd 运行用户组' "$(id -gn)")}"
DOMAIN="${DOMAIN:-$(ask_required '部署域名(可填写 IP 或 localhost' 'localhost')}" # 自动尝试获取公网 IP 作为默认域名值
local detected_ip=""
if [[ -z "${DOMAIN:-}" ]]; then
log "正在尝试自动获取公网 IP..."
detected_ip=$(curl -s --connect-timeout 5 https://ifconfig.me || curl -s --connect-timeout 5 https://api.ipify.org || echo "")
if [[ -n "${detected_ip}" ]]; then
log "自动检测到公网 IP: ${detected_ip}"
fi
fi
DOMAIN="${DOMAIN:-$(ask_required '部署域名(可填写 IP 或 localhost' "${detected_ip:-localhost}")}"
validate_domain_like "${DOMAIN}" || warn "域名/IP 形态看起来不标准,请再次确认: ${DOMAIN}" validate_domain_like "${DOMAIN}" || warn "域名/IP 形态看起来不标准,请再次确认: ${DOMAIN}"
ENV_FILE="${ENV_FILE:-$(ask_required '环境变量文件路径' '/etc/bigtime/bigtime.env')}" ENV_FILE="${ENV_FILE:-$(ask_required '环境变量文件路径' '/etc/bigtime/bigtime.env')}"
@@ -486,53 +496,65 @@ main() {
PYTHON_BIN="${PYTHON_BIN:-$(ask 'Python 可执行文件路径' "${APP_DIR}/.venv/bin/python")}" PYTHON_BIN="${PYTHON_BIN:-$(ask 'Python 可执行文件路径' "${APP_DIR}/.venv/bin/python")}"
[[ -n "${PYTHON_BIN}" ]] || fail "Python 路径不能为空" [[ -n "${PYTHON_BIN}" ]] || fail "Python 路径不能为空"
echo "" local SKIP_ENV_CONFIG=false
echo -e "${CYAN}运行参数${NC}" if [[ -f "${ENV_FILE}" ]]; then
TICKERS="${TICKERS:-$(ask '默认股票池(逗号分隔)' 'AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN')}" echo ""
FIN_DATA_SOURCE="${FIN_DATA_SOURCE:-$(ask '行情数据源finnhub/yfinance/financial_datasets' 'finnhub')}" if confirm "检测到环境变量文件 ${ENV_FILE} 已存在,是否跳过详细参数配置并保留现有文件?" "Y"; then
MODEL_NAME="${MODEL_NAME:-$(ask '默认模型名' 'qwen3-max')}" SKIP_ENV_CONFIG=true
MAX_COMM_CYCLES="${MAX_COMM_CYCLES:-$(ask_required '最大讨论轮数' '2')}" fi
validate_numeric "${MAX_COMM_CYCLES}" || fail "最大讨论轮数必须是数字: ${MAX_COMM_CYCLES}"
MARGIN_REQUIREMENT="${MARGIN_REQUIREMENT:-$(ask_required '保证金比例' '0.5')}"
validate_numeric "${MARGIN_REQUIREMENT}" || fail "保证金比例必须是数字: ${MARGIN_REQUIREMENT}"
echo ""
echo -e "${CYAN}密钥配置${NC}"
FINANCIAL_DATASETS_API_KEY="${FINANCIAL_DATASETS_API_KEY:-$(ask 'FINANCIAL_DATASETS_API_KEY可留空' '')}"
FINNHUB_API_KEY="${FINNHUB_API_KEY:-$(ask 'FINNHUB_API_KEYlive 模式建议填写)' '')}"
POLYGON_API_KEY="${POLYGON_API_KEY:-$(ask 'POLYGON_API_KEY可留空' '')}"
OPENAI_API_KEY="${OPENAI_API_KEY:-$(ask 'OPENAI_API_KEY可留空' '')}"
OPENAI_BASE_URL="${OPENAI_BASE_URL:-$(ask 'OPENAI_BASE_URL可留空' '')}"
DASHSCOPE_API_KEY="${DASHSCOPE_API_KEY:-$(ask 'DASHSCOPE_API_KEY可留空' '')}"
MEMORY_API_KEY="${MEMORY_API_KEY:-$(ask 'MEMORY_API_KEY可留空' '')}"
if [[ "${FIN_DATA_SOURCE}" == "finnhub" && -z "${FINNHUB_API_KEY}" ]]; then
warn "你选择了 finnhub 作为数据源,但 FINNHUB_API_KEY 为空。live 模式下通常会失败。"
fi
if [[ -z "${OPENAI_API_KEY}" && -z "${DASHSCOPE_API_KEY}" ]]; then
warn "OPENAI_API_KEY 和 DASHSCOPE_API_KEY 都为空,模型调用可能无法工作。"
fi fi
if confirm "使用 Docker 沙盒执行技能?" "N" "${AUTO_USE_DOCKER}"; then if ! ${SKIP_ENV_CONFIG}; then
SKILL_SANDBOX_MODE="docker" echo ""
echo -e "${CYAN}运行参数${NC}"
TICKERS="${TICKERS:-$(ask '默认股票池(逗号分隔)' 'AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN')}"
FIN_DATA_SOURCE="${FIN_DATA_SOURCE:-$(ask '行情数据源finnhub/yfinance/financial_datasets' 'finnhub')}"
MODEL_NAME="${MODEL_NAME:-$(ask '默认模型名' 'qwen3-max')}"
MAX_COMM_CYCLES="${MAX_COMM_CYCLES:-$(ask_required '最大讨论轮数' '2')}"
validate_numeric "${MAX_COMM_CYCLES}" || fail "最大讨论轮数必须是数字: ${MAX_COMM_CYCLES}"
MARGIN_REQUIREMENT="${MARGIN_REQUIREMENT:-$(ask_required '保证金比例' '0.5')}"
validate_numeric "${MARGIN_REQUIREMENT}" || fail "保证金比例必须是数字: ${MARGIN_REQUIREMENT}"
echo ""
echo -e "${CYAN}密钥配置${NC}"
FINANCIAL_DATASETS_API_KEY="${FINANCIAL_DATASETS_API_KEY:-$(ask 'FINANCIAL_DATASETS_API_KEY可留空' '')}"
FINNHUB_API_KEY="${FINNHUB_API_KEY:-$(ask 'FINNHUB_API_KEYlive 模式建议填写)' '')}"
POLYGON_API_KEY="${POLYGON_API_KEY:-$(ask 'POLYGON_API_KEY可留空' '')}"
OPENAI_API_KEY="${OPENAI_API_KEY:-$(ask 'OPENAI_API_KEY可留空' '')}"
OPENAI_BASE_URL="${OPENAI_BASE_URL:-$(ask 'OPENAI_BASE_URL可留空' '')}"
DASHSCOPE_API_KEY="${DASHSCOPE_API_KEY:-$(ask 'DASHSCOPE_API_KEY可留空' '')}"
MEMORY_API_KEY="${MEMORY_API_KEY:-$(ask 'MEMORY_API_KEY可留空' '')}"
if [[ "${FIN_DATA_SOURCE}" == "finnhub" && -z "${FINNHUB_API_KEY}" ]]; then
warn "你选择了 finnhub 作为数据源,但 FINNHUB_API_KEY 为空。live 模式下通常会失败。"
fi
if [[ -z "${OPENAI_API_KEY}" && -z "${DASHSCOPE_API_KEY}" ]]; then
warn "OPENAI_API_KEY 和 DASHSCOPE_API_KEY 都为空,模型调用可能无法工作。"
fi
if confirm "使用 Docker 沙盒执行技能?" "N" "${AUTO_USE_DOCKER}"; then
SKILL_SANDBOX_MODE="docker"
else
SKILL_SANDBOX_MODE="none"
fi
echo ""
echo -e "${CYAN}当前部署摘要${NC}"
echo " 应用目录: ${APP_DIR}"
echo " 运行用户: ${SERVICE_USER}:${SERVICE_GROUP}"
echo " 域名: ${DOMAIN}"
echo " 环境文件: ${ENV_FILE}"
echo " Python: ${PYTHON_BIN}"
echo " 数据源: ${FIN_DATA_SOURCE:-}"
echo " 模型: ${MODEL_NAME:-}"
echo " 沙盒模式: ${SKILL_SANDBOX_MODE:-none}"
echo ""
if ! confirm "确认以上配置并继续写入系统文件?" "Y"; then
fail "用户取消部署。"
fi
else else
SKILL_SANDBOX_MODE="none" echo -e "${GREEN}将使用现有的环境文件,跳过详细参数配置。${NC}"
fi
echo ""
echo -e "${CYAN}当前部署摘要${NC}"
echo " 应用目录: ${APP_DIR}"
echo " 运行用户: ${SERVICE_USER}:${SERVICE_GROUP}"
echo " 域名: ${DOMAIN}"
echo " 环境文件: ${ENV_FILE}"
echo " Python: ${PYTHON_BIN}"
echo " 数据源: ${FIN_DATA_SOURCE}"
echo " 模型: ${MODEL_NAME}"
echo " 沙盒模式: ${SKILL_SANDBOX_MODE}"
echo ""
if ! confirm "确认以上配置并继续写入系统文件?" "Y"; then
fail "用户取消部署。"
fi fi
if [[ ! -x "${PYTHON_BIN}" ]]; then if [[ ! -x "${PYTHON_BIN}" ]]; then
@@ -546,10 +568,12 @@ main() {
"${PYTHON_BIN}" -m pip install -e "${APP_DIR}" "${PYTHON_BIN}" -m pip install -e "${APP_DIR}"
log "构建前端" log "构建前端"
(cd "${APP_DIR}/frontend" && npm ci && npm run build) (cd "${APP_DIR}/frontend" && npm install && npm run build)
log "写入环境变量文件 ${ENV_FILE}" if ! ${SKIP_ENV_CONFIG}; then
write_env_file log "写入环境变量文件 ${ENV_FILE}"
write_env_file
fi
if confirm "生成并安装 systemd unit" "Y" "${AUTO_INSTALL_SYSTEMD}"; then if confirm "生成并安装 systemd unit" "Y" "${AUTO_INSTALL_SYSTEMD}"; then
render_systemd_unit "Agent Service" "backend.apps.agent_service:app" "8000" "1" "1024M" "/etc/systemd/system/bigtime-agent.service" render_systemd_unit "Agent Service" "backend.apps.agent_service:app" "8000" "1" "1024M" "/etc/systemd/system/bigtime-agent.service"
@@ -568,20 +592,50 @@ main() {
if confirm "生成并安装 nginx 配置?" "Y" "${AUTO_INSTALL_NGINX}"; then if confirm "生成并安装 nginx 配置?" "Y" "${AUTO_INSTALL_NGINX}"; then
local use_tls="no" local use_tls="no"
if confirm "使用 HTTPS/Let's Encrypt 证书路径?" "N" "${AUTO_USE_TLS}"; then if confirm "使用 HTTPS/Let's Encrypt 证书路径?" "N" "${AUTO_USE_TLS}"; then
use_tls="yes"
SSL_CERT_PATH="${SSL_CERT_PATH:-$(ask_required 'SSL 证书 fullchain.pem 路径' "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem")}" SSL_CERT_PATH="${SSL_CERT_PATH:-$(ask_required 'SSL 证书 fullchain.pem 路径' "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem")}"
SSL_KEY_PATH="${SSL_KEY_PATH:-$(ask_required 'SSL 私钥 privkey.pem 路径' "/etc/letsencrypt/live/${DOMAIN}/privkey.pem")}" SSL_KEY_PATH="${SSL_KEY_PATH:-$(ask_required 'SSL 私钥 privkey.pem 路径' "/etc/letsencrypt/live/${DOMAIN}/privkey.pem")}"
[[ -f "${SSL_CERT_PATH}" ]] || warn "证书文件当前不存在: ${SSL_CERT_PATH}"
[[ -f "${SSL_KEY_PATH}" ]] || warn "私钥文件当前不存在: ${SSL_KEY_PATH}" local ssl_err=0
[[ -f "${SSL_CERT_PATH}" ]] || { warn "SSL 证书文件不存在: ${SSL_CERT_PATH}"; ssl_err=1; }
[[ -f "${SSL_KEY_PATH}" ]] || { warn "SSL 私钥文件不存在: ${SSL_KEY_PATH}"; ssl_err=1; }
[[ -f "/etc/letsencrypt/options-ssl-nginx.conf" ]] || { warn "缺失 /etc/letsencrypt/options-ssl-nginx.conf请检查 certbot 配置"; ssl_err=1; }
[[ -f "/etc/letsencrypt/ssl-dhparams.pem" ]] || { warn "缺失 /etc/letsencrypt/ssl-dhparams.pem请检查 certbot 配置"; ssl_err=1; }
if [[ ${ssl_err} -eq 0 ]]; then
use_tls="yes"
else
warn "由于 SSL 关键文件缺失,将回退至 HTTP 模式,以确保 Nginx 能通过配置检查。"
use_tls="no"
fi
else else
SSL_CERT_PATH="" SSL_CERT_PATH=""
SSL_KEY_PATH="" SSL_KEY_PATH=""
fi fi
NGINX_TARGET="/etc/nginx/conf.d/bigtime.conf" NGINX_TARGET="/etc/nginx/conf.d/bigtime.conf"
render_nginx_conf "${NGINX_TARGET}" "${use_tls}" render_nginx_conf "${NGINX_TARGET}" "${use_tls}"
if confirm "立即执行 nginx -t 并 reload" "Y" "${AUTO_RELOAD_NGINX}"; then if confirm "立即执行 nginx -t 并生效配置" "Y" "${AUTO_RELOAD_NGINX}"; then
sudo nginx -t log "正在验证 Nginx 配置..."
sudo systemctl reload nginx if ! sudo nginx -t; then
fail "Nginx 配置检查失败请根据上方报错信息调整。常见的错误包括80/443 端口被占用,或 server_name 冲突。"
fi
if systemctl is-active --quiet nginx; then
log "Nginx 正在运行,执行 reload..."
sudo systemctl reload nginx
else
log "Nginx 未运行,尝试启动..."
sudo systemctl enable --now nginx
fi
# 关键修复:确保 nginx 用户对 /root 路径有 x 权限
if [[ "${APP_DIR}" == /root/* ]]; then
log "检测到应用部署在 /root 下,正在修复父目录访问权限..."
sudo chmod o+x /root 2>/dev/null || true
sudo chmod o+x "$(dirname "${APP_DIR}")" 2>/dev/null || true
sudo chmod -R o+rX "${APP_DIR}"
fi
log "Nginx 配置已生效。"
fi fi
fi fi

View File

@@ -42,7 +42,7 @@ Recommended frontend mode:
```bash ```bash
cd frontend cd frontend
npm ci npm install
npm run build npm run build
``` ```

View File

@@ -40,7 +40,7 @@ sudo systemctl enable --now bigtime-runtime.service
Recommended production frontend mode: Recommended production frontend mode:
- build with `cd frontend && npm ci && npm run build` - build with `cd frontend && npm install && npm run build`
- let `nginx` serve `frontend/dist` directly - let `nginx` serve `frontend/dist` directly
The repository also contains `backend.apps.frontend_service`, but for The repository also contains `backend.apps.frontend_service`, but for

View File

@@ -17,7 +17,11 @@ class NewsServiceClient:
self._client: httpx.AsyncClient | None = None self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> "NewsServiceClient": async def __aenter__(self) -> "NewsServiceClient":
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=30.0) self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=90.0,
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
)
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:

View File

@@ -21,7 +21,11 @@ class TradingServiceClient:
self._client: httpx.AsyncClient | None = None self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> "TradingServiceClient": async def __aenter__(self) -> "TradingServiceClient":
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=30.0) self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=60.0,
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
)
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: