Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions haoinvest/fx.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import httpx

from .http_retry import api_retry

# Hardcoded fallback rates (updated manually when needed)
_FALLBACK_RATES = {
("USD", "CNY"): 7.25,
Expand Down Expand Up @@ -57,6 +59,7 @@ def _get_rate(from_ccy: str, to_ccy: str) -> float:
raise ValueError(f"No exchange rate available for {from_ccy} → {to_ccy}")


@api_retry
def _fetch_live_rate(from_ccy: str, to_ccy: str) -> float:
"""Fetch live rate from a free API. Raises on failure."""
# exchangerate-api.com free tier
Expand Down
53 changes: 53 additions & 0 deletions haoinvest/http_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Shared retry decorator for external API calls.

Provides exponential backoff with jitter for transient network errors
and server-side failures (5xx). Does NOT retry client errors (4xx).
"""

import logging

import httpx
import requests
from tenacity import (
retry,
retry_if_exception,
stop_after_attempt,
wait_exponential_jitter,
)

logger = logging.getLogger(__name__)


def _is_retryable(exc: BaseException) -> bool:
"""Return True for transient errors worth retrying."""
# Network-level errors (connection refused, DNS failure, timeout)
if isinstance(exc, (requests.ConnectionError, requests.Timeout)):
return True
if isinstance(exc, (httpx.ConnectError, httpx.TimeoutException)):
return True

# HTTP 5xx server errors
if isinstance(exc, requests.HTTPError) and exc.response is not None:
return exc.response.status_code >= 500
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code >= 500

return False


def _log_retry(retry_state) -> None:
logger.debug(
"Retrying %s (attempt %d) after %s",
retry_state.fn.__name__ if retry_state.fn else "unknown",
retry_state.attempt_number,
retry_state.outcome.exception() if retry_state.outcome else "unknown",
)


api_retry = retry(
stop=stop_after_attempt(3),
wait=wait_exponential_jitter(initial=1, max=10, jitter=0.5),
retry=retry_if_exception(_is_retryable),
reraise=True,
before_sleep=_log_retry,
)
4 changes: 4 additions & 0 deletions haoinvest/market/crypto_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import httpx

from ..http_retry import api_retry
from ..models import BasicInfo, MarketType, PriceBar
from .provider import MarketProvider

Expand Down Expand Up @@ -50,6 +51,7 @@ def client(self) -> httpx.Client:
self._client = httpx.Client(timeout=10.0)
return self._client

@api_retry
def get_current_price(self, symbol: str) -> float:
"""Get latest USD price for a crypto asset.

Expand All @@ -67,6 +69,7 @@ def get_current_price(self, symbol: str) -> float:
raise ValueError(f"Crypto asset {symbol} (id={coin_id}) not found")
return float(data[coin_id]["usd"])

@api_retry
def get_price_history(self, symbol: str, start: date, end: date) -> list[PriceBar]:
"""Get daily OHLC data for a crypto asset (close prices only from CoinGecko free tier)."""
coin_id = _to_coingecko_id(symbol)
Expand All @@ -93,6 +96,7 @@ def get_price_history(self, symbol: str, start: date, end: date) -> list[PriceBa
)
return bars

@api_retry
def get_basic_info(self, symbol: str) -> BasicInfo:
"""Get basic info for a crypto asset."""
coin_id = _to_coingecko_id(symbol)
Expand Down
38 changes: 23 additions & 15 deletions haoinvest/market/sources/eastmoney.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import requests

from ...http_retry import api_retry
from ...models import BasicInfo
from ._common import exchange_prefix, parse_float

Expand All @@ -22,6 +23,7 @@
)


@api_retry
def get_basic_info(symbol: str) -> BasicInfo:
"""Get basic company info from eastmoney emweb CompanySurvey API."""
code = f"{exchange_prefix(symbol)}{symbol}"
Expand All @@ -47,21 +49,7 @@ def get_financial_indicators(symbol: str) -> dict:
Gracefully returns empty dict on any failure.
"""
try:
r = requests.get(
_DATACENTER_URL,
params={
"reportName": "RPT_LICO_FN_CPD",
"columns": _FIN_COLUMNS,
"filter": f'(SECURITY_CODE="{symbol}")',
"pageNumber": "1",
"pageSize": "1",
"sortColumns": "NOTICE_DATE",
"sortTypes": "-1",
},
timeout=10,
)
r.raise_for_status()
body = r.json()
body = _fetch_financial_data(symbol)

if not body.get("success") or not body.get("result"):
return {}
Expand All @@ -86,3 +74,23 @@ def get_financial_indicators(symbol: str) -> dict:
except Exception as e:
logger.debug("Eastmoney financial indicators failed for %s: %s", symbol, e)
return {}


@api_retry
def _fetch_financial_data(symbol: str) -> dict:
"""Fetch financial report data from eastmoney datacenter (with retry)."""
r = requests.get(
_DATACENTER_URL,
params={
"reportName": "RPT_LICO_FN_CPD",
"columns": _FIN_COLUMNS,
"filter": f'(SECURITY_CODE="{symbol}")',
"pageNumber": "1",
"pageSize": "1",
"sortColumns": "NOTICE_DATE",
"sortTypes": "-1",
},
timeout=10,
)
r.raise_for_status()
return r.json()
38 changes: 24 additions & 14 deletions haoinvest/market/sources/sina.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@

import requests

from ...http_retry import api_retry
from ._common import market_prefix, parse_float


@api_retry
def get_current_price(symbol: str) -> float:
"""Get current price from Sina Finance API."""
prefix = market_prefix(symbol)
Expand Down Expand Up @@ -81,20 +83,7 @@ def get_sector_constituents(sector_name: str) -> list[dict]:
"Use 'market sector-list' to see available sectors."
)

r = requests.get(
"https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php"
"/Market_Center.getHQNodeData",
params={
"page": 1,
"num": 200,
"sort": "changepercent",
"asc": 0,
"node": node_code,
},
headers={"Referer": "https://finance.sina.com.cn"},
timeout=10,
)
r.encoding = "gbk"
r = _fetch_sector_constituents(node_code)
stocks = r.json()

rows = []
Expand All @@ -115,6 +104,27 @@ def get_sector_constituents(sector_name: str) -> list[dict]:
return rows


@api_retry
def _fetch_sector_constituents(node_code: str) -> requests.Response:
"""Fetch sector constituent data from Sina API (with retry)."""
r = requests.get(
"https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php"
"/Market_Center.getHQNodeData",
params={
"page": 1,
"num": 200,
"sort": "changepercent",
"asc": 0,
"node": node_code,
},
headers={"Referer": "https://finance.sina.com.cn"},
timeout=10,
)
r.encoding = "gbk"
return r


@api_retry
def _fetch_sector_data() -> dict[str, list[str]]:
"""Fetch Sina industry board data. Returns {node_code: [fields...]}."""
r = requests.get(
Expand Down
23 changes: 16 additions & 7 deletions haoinvest/market/sources/tencent.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import requests

from ...http_retry import api_retry
from ...models import MarketType, PriceBar
from ._common import market_prefix, parse_float

Expand All @@ -21,6 +22,7 @@
_PB = 46


@api_retry
def get_current_price(symbol: str) -> float:
"""Get current price from Tencent Finance quote API."""
prefix = market_prefix(symbol)
Expand All @@ -38,6 +40,7 @@ def get_current_price(symbol: str) -> float:
return price


@api_retry
def get_price_history(symbol: str, start: date, end: date) -> list[PriceBar]:
"""Get forward-adjusted daily klines from Tencent Finance API."""
prefix = market_prefix(symbol)
Expand Down Expand Up @@ -90,13 +93,7 @@ def get_valuation(symbol: str) -> dict:
"total_market_cap": None,
}
try:
prefix = market_prefix(symbol)
r = requests.get(
f"https://qt.gtimg.cn/q={prefix}{symbol}",
timeout=10,
)
r.raise_for_status()
fields = r.text.strip().split("~")
fields = _fetch_quote_fields(symbol)
if len(fields) <= _PB:
logger.debug(
"Tencent response too short for %s: %d fields", symbol, len(fields)
Expand All @@ -115,3 +112,15 @@ def get_valuation(symbol: str) -> dict:
logger.debug("Tencent valuation failed for %s: %s", symbol, e)

return result


@api_retry
def _fetch_quote_fields(symbol: str) -> list[str]:
"""Fetch and parse quote fields from Tencent API (with retry)."""
prefix = market_prefix(symbol)
r = requests.get(
f"https://qt.gtimg.cn/q={prefix}{symbol}",
timeout=10,
)
r.raise_for_status()
return r.text.strip().split("~")
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"pandas-ta-classic==0.4.47",
"quantstats==0.0.81",
"pyportfolioopt==1.6.0",
"tenacity==9.1.4",
]

[project.scripts]
Expand Down
Loading
Loading