Skip to content

Commit 3e8982c

Browse files
committed
v4.0.6
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
1 parent a8ea0c7 commit 3e8982c

7 files changed

Lines changed: 290 additions & 11 deletions

File tree

backend_api_python/app/data_sources/asia_stock_kline.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
from __future__ import annotations
1717

1818
import os
19+
import threading
1920
import time
2021
from contextlib import contextmanager
21-
from datetime import datetime, timedelta
22+
from datetime import datetime, timedelta, timezone
2223
from typing import Any, Dict, Generator, List, Optional
2324

2425
import pandas as pd
@@ -148,6 +149,41 @@ def _get_twelve_data_api_key() -> str:
148149
"1W": "1week",
149150
}
150151

152+
_TD_DAILY_LIMIT_LOCK = threading.Lock()
153+
_TD_DAILY_LIMIT_UNTIL = 0.0
154+
155+
156+
def _twelvedata_daily_limit_active() -> bool:
157+
with _TD_DAILY_LIMIT_LOCK:
158+
return time.time() < _TD_DAILY_LIMIT_UNTIL
159+
160+
161+
def _mark_twelvedata_daily_limited(symbol: str, exchange: str, message: str) -> None:
162+
global _TD_DAILY_LIMIT_UNTIL
163+
now = datetime.now(timezone.utc)
164+
tomorrow = (now + timedelta(days=1)).date()
165+
until = datetime(
166+
tomorrow.year,
167+
tomorrow.month,
168+
tomorrow.day,
169+
0,
170+
5,
171+
tzinfo=timezone.utc,
172+
).timestamp()
173+
first = False
174+
with _TD_DAILY_LIMIT_LOCK:
175+
if time.time() >= _TD_DAILY_LIMIT_UNTIL:
176+
first = True
177+
_TD_DAILY_LIMIT_UNTIL = max(_TD_DAILY_LIMIT_UNTIL, until)
178+
if first:
179+
logger.warning(
180+
"TwelveData daily API credits exhausted for %s/%s; "
181+
"skipping TwelveData requests until next UTC day. Last message: %s",
182+
symbol,
183+
exchange,
184+
message,
185+
)
186+
151187

152188
def _td_symbol_and_exchange(tencent_code: str, is_hk: bool) -> tuple[str, str]:
153189
"""Convert Tencent code to Twelve Data (symbol, exchange).
@@ -183,6 +219,8 @@ def fetch_twelvedata_klines(
183219
interval = _TD_INTERVAL_MAP.get(timeframe)
184220
if not interval:
185221
return []
222+
if _twelvedata_daily_limit_active():
223+
return []
186224

187225
symbol, exchange = _td_symbol_and_exchange(tencent_code, is_hk)
188226
merge_factor = _MERGE_FACTOR_MAP.get(timeframe, 1)
@@ -223,7 +261,10 @@ def fetch_twelvedata_klines(
223261
if data.get("status") != "ok" or "values" not in data:
224262
code = data.get("code", "")
225263
msg = data.get("message", str(data))
226-
if code == 429 or "API credits" in msg or "minute limit" in msg:
264+
msg_l = str(msg).lower()
265+
if "api credits" in msg_l or "for the day" in msg_l:
266+
_mark_twelvedata_daily_limited(symbol, exchange, msg)
267+
elif code == 429 or "minute limit" in msg_l:
227268
logger.warning("TwelveData rate limit for %s/%s: %s", symbol, exchange, msg)
228269
elif "Pro" in msg or "Venture" in msg or "upgrading" in msg:
229270
logger.debug("TwelveData plan limit %s/%s tf=%s: %s", symbol, exchange, timeframe, msg)

backend_api_python/app/data_sources/factory.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
数据源工厂
33
根据市场类型返回对应的数据源
44
"""
5+
import os
6+
import threading
7+
import time
58
from typing import Dict, List, Any, Optional
69

710
from app.data_sources.base import BaseDataSource
@@ -10,6 +13,14 @@
1013

1114
logger = get_logger(__name__)
1215

16+
17+
def _env_positive_int(key: str, default: int) -> int:
18+
try:
19+
value = int(os.getenv(key, str(default)))
20+
return value if value > 0 else default
21+
except Exception:
22+
return default
23+
1324
_MARKET_ALIASES: Dict[str, str] = {
1425
"crypto": "Crypto",
1526
"cryptocurrency": "Crypto",
@@ -26,7 +37,22 @@
2637
"alpaca": "USStock",
2738
"ibkr": "USStock",
2839
"cnstock": "CNStock",
40+
"cn_stock": "CNStock",
41+
"ashare": "CNStock",
42+
"a_share": "CNStock",
43+
"astock": "CNStock",
44+
"a_stock": "CNStock",
45+
"cn": "CNStock",
46+
"china": "CNStock",
47+
"chinastock": "CNStock",
2948
"hkstock": "HKStock",
49+
"hk_stock": "HKStock",
50+
"hshare": "HKStock",
51+
"h_share": "HKStock",
52+
"hkshare": "HKStock",
53+
"hk_share": "HKStock",
54+
"hk": "HKStock",
55+
"hongkong": "HKStock",
3056
"futures": "Futures",
3157
"moex": "MOEX",
3258
"rustock": "MOEX",
@@ -43,10 +69,29 @@ class DataSourceFactory:
4369
"""
4470

4571
_sources: Dict[str, BaseDataSource] = {}
72+
_noise_lock = threading.Lock()
73+
_noise_seen: Dict[str, tuple[float, int]] = {}
74+
_noise_interval_sec = _env_positive_int("LOG_DEDUPE_INTERVAL_SEC", 60)
4675

4776
# Markets that pass through normalize_market unchanged.
4877
_CANONICAL_MARKETS = ("Crypto", "Forex", "Futures", "USStock", "CNStock", "HKStock", "MOEX")
4978

79+
@classmethod
80+
def _log_limited(cls, level: str, key: str, message: str, *args: Any) -> None:
81+
"""Log noisy market-data failures at most once per key per interval."""
82+
now = time.monotonic()
83+
with cls._noise_lock:
84+
last, suppressed = cls._noise_seen.get(key, (0.0, 0))
85+
if last > 0 and now - last < cls._noise_interval_sec:
86+
cls._noise_seen[key] = (last, suppressed + 1)
87+
return
88+
cls._noise_seen[key] = (now, 0)
89+
90+
if suppressed:
91+
message = f"{message} (suppressed {suppressed} duplicate log(s))"
92+
log_fn = getattr(logger, level, logger.warning)
93+
log_fn(message, *args)
94+
5095
@classmethod
5196
def normalize_market(cls, market: str) -> str:
5297
"""
@@ -73,6 +118,14 @@ def normalize_market(cls, market: str) -> str:
73118
key = raw.lower().replace(" ", "").replace("-", "_")
74119
if key in _MARKET_ALIASES:
75120
return _MARKET_ALIASES[key]
121+
cls._log_limited(
122+
"warning",
123+
f"unknown-market:{raw}",
124+
"DataSourceFactory.normalize_market(): unknown market %r; "
125+
"passing through as-is; downstream get_source() will likely fail.",
126+
raw,
127+
)
128+
return raw
76129
logger.warning(
77130
"DataSourceFactory.normalize_market(): unknown market %r — "
78131
"passing through as-is; downstream get_source() will likely fail.",
@@ -178,16 +231,24 @@ def get_kline(
178231
Returns:
179232
K线数据列表
180233
"""
234+
m = cls.normalize_market(market or "")
181235
try:
182-
m = cls.normalize_market(market or "")
183236
source = cls._resolve_source(m, exchange_id=exchange_id, market_type=market_type)
184237
klines = source.get_kline(symbol, timeframe, limit, before_time, after_time)
185238

186239
klines.sort(key=lambda x: x['time'])
187240

188241
return klines
189242
except Exception as e:
190-
logger.error(f"Failed to fetch K-lines {market}:{symbol} (normalized={cls.normalize_market(market or '')}) - {str(e)}")
243+
cls._log_limited(
244+
"error",
245+
f"kline:{m}:{symbol}:{type(e).__name__}:{str(e)[:160]}",
246+
"Failed to fetch K-lines %s:%s (normalized=%s) - %s",
247+
market,
248+
symbol,
249+
m,
250+
str(e),
251+
)
191252
return []
192253

193254
@classmethod
@@ -228,14 +289,26 @@ def get_ticker(cls, market: str, symbol: str, exchange_id: Optional[str] = None,
228289
...
229290
}
230291
"""
292+
m = cls.normalize_market(market or "")
231293
try:
232-
m = cls.normalize_market(market or "")
233294
source = cls._resolve_source(m, exchange_id=exchange_id, market_type=market_type)
234295
return source.get_ticker(symbol)
235296
except NotImplementedError:
236-
logger.warning(f"get_ticker not implemented for market: {market}")
297+
cls._log_limited(
298+
"warning",
299+
f"ticker-not-implemented:{m}",
300+
"get_ticker not implemented for market: %s",
301+
market,
302+
)
237303
return {'last': 0, 'symbol': symbol}
238304
except Exception as e:
239-
logger.error(f"Failed to fetch ticker {market}:{symbol} - {str(e)}")
305+
cls._log_limited(
306+
"error",
307+
f"ticker:{m}:{symbol}:{type(e).__name__}:{str(e)[:160]}",
308+
"Failed to fetch ticker %s:%s - %s",
309+
market,
310+
symbol,
311+
str(e),
312+
)
240313
return {'last': 0, 'symbol': symbol}
241314

backend_api_python/app/services/mfa_service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ def start_setup(self, user_id: int, label: str) -> Dict[str, Any]:
148148
last_used_counter = 0,
149149
confirmed_at = NULL,
150150
updated_at = NOW()
151+
RETURNING user_id
151152
""",
152153
(user_id, encrypted),
153154
)

backend_api_python/app/services/trading_executor.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ def __init__(self):
7777
self.kline_service = KlineService()
7878
# Throttle writes to qd_strategy_logs (heartbeat), per strategy_id -> monotonic time
7979
self._strategy_ui_log_last_tick_ts = {} # type: Dict[int, float]
80+
self._console_tick_last_ts: Dict[int, float] = {}
81+
self._console_tick_lock = threading.Lock()
82+
try:
83+
self._console_tick_interval_sec = max(1, int(os.getenv("STRATEGY_TICK_LOG_INTERVAL_SEC", "60")))
84+
except Exception:
85+
self._console_tick_interval_sec = 60
8086

8187
self.max_threads = int(os.getenv('STRATEGY_MAX_THREADS', '64'))
8288
self._last_start_failure: str = ""
@@ -268,7 +274,18 @@ def _console_print(self, msg: str) -> None:
268274
Local-only observability: print to stdout so user can see strategy status in console.
269275
"""
270276
try:
271-
print(str(msg or ""), flush=True)
277+
text = str(msg or "")
278+
if "] tick price=" in text:
279+
m = re.match(r"\[strategy:(\d+)\]\s+tick\b", text)
280+
if m:
281+
sid = int(m.group(1))
282+
now = time.monotonic()
283+
with self._console_tick_lock:
284+
last = self._console_tick_last_ts.get(sid, 0.0)
285+
if last > 0 and now - last < self._console_tick_interval_sec:
286+
return
287+
self._console_tick_last_ts[sid] = now
288+
print(text, flush=True)
272289
except Exception:
273290
pass
274291

@@ -2527,7 +2544,7 @@ def _is_fatal_error(err: Exception, msg: str) -> bool:
25272544
if signal_time == 0 or (current_ts - signal_time) < expiration_threshold:
25282545
valid_signals.append(s)
25292546
else:
2530-
logger.warning(f"Signal expired and removed: {s}")
2547+
logger.debug(f"Signal expired and removed: {s}")
25312548
if len(valid_signals) != len(pending_signals):
25322549
pending_signals = valid_signals
25332550

backend_api_python/app/utils/strategy_runtime_logs.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ def append_strategy_log(strategy_id: int, level: str, message: str) -> None:
2424
cur.execute(
2525
"""
2626
INSERT INTO qd_strategy_logs (strategy_id, level, message, timestamp)
27-
VALUES (?, ?, ?, ?)
27+
SELECT ?, ?, ?, ?
28+
WHERE EXISTS (
29+
SELECT 1 FROM qd_strategies_trading WHERE id = ?
30+
)
2831
""",
29-
(sid, lv, msg, datetime.now(timezone.utc)),
32+
(sid, lv, msg, datetime.now(timezone.utc), sid),
3033
)
3134
db.commit()
3235
cur.close()
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from contextlib import contextmanager
2+
3+
from app.services import mfa_service
4+
from app.utils import strategy_runtime_logs
5+
6+
7+
class _CaptureCursor:
8+
def __init__(self):
9+
self.calls = []
10+
self.closed = False
11+
12+
def execute(self, sql, params=None):
13+
self.calls.append((sql, params))
14+
15+
def close(self):
16+
self.closed = True
17+
18+
19+
class _CaptureConn:
20+
def __init__(self):
21+
self.cursor_obj = _CaptureCursor()
22+
self.committed = False
23+
24+
def cursor(self):
25+
return self.cursor_obj
26+
27+
def commit(self):
28+
self.committed = True
29+
30+
31+
@contextmanager
32+
def _capture_connection(conn):
33+
yield conn
34+
35+
36+
class _FakeTotp:
37+
def __init__(self, secret):
38+
self.secret = secret
39+
40+
def provisioning_uri(self, name, issuer_name):
41+
return f"otpauth://totp/{issuer_name}:{name}?secret={self.secret}"
42+
43+
44+
class _FakePyotp:
45+
@staticmethod
46+
def random_base32():
47+
return "ABCDEFGHIJKLMNOP"
48+
49+
TOTP = _FakeTotp
50+
51+
52+
def test_append_strategy_log_uses_parent_exists_guard(monkeypatch):
53+
conn = _CaptureConn()
54+
monkeypatch.setattr(strategy_runtime_logs, "get_db_connection", lambda: _capture_connection(conn))
55+
56+
strategy_runtime_logs.append_strategy_log(3347, "info", "Strategy execution loop exited")
57+
58+
sql, params = conn.cursor_obj.calls[0]
59+
assert "WHERE EXISTS" in sql
60+
assert "qd_strategies_trading" in sql
61+
assert params[0] == 3347
62+
assert params[-1] == 3347
63+
assert conn.committed
64+
assert conn.cursor_obj.closed
65+
66+
67+
def test_mfa_start_setup_returns_user_id_not_missing_id(monkeypatch):
68+
conn = _CaptureConn()
69+
monkeypatch.setenv("MFA_ENABLED", "true")
70+
monkeypatch.setattr(mfa_service.MfaService, "ensure_schema", lambda self: None)
71+
monkeypatch.setattr(mfa_service, "get_db_connection", lambda: _capture_connection(conn))
72+
monkeypatch.setattr(mfa_service, "encrypt_credential_blob", lambda secret: f"encrypted:{secret}")
73+
74+
service = mfa_service.MfaService()
75+
monkeypatch.setattr(service, "_load_totp_libs", lambda: (_FakePyotp, object()))
76+
monkeypatch.setattr(service, "_make_qr_data_url", lambda _qrcode, _uri: "data:image/png;base64,test")
77+
78+
result = service.start_setup(9383, "user@example.com")
79+
80+
sql, params = conn.cursor_obj.calls[0]
81+
assert "RETURNING user_id" in sql
82+
assert "RETURNING id" not in sql
83+
assert params == (9383, "encrypted:ABCDEFGHIJKLMNOP")
84+
assert result["secret"] == "ABCDEFGHIJKLMNOP"
85+
assert conn.committed

0 commit comments

Comments
 (0)