Changes: - Added new API endpoints for continuing and confirming Optuna jobs, allowing for better management of ongoing studies. - Introduced detailed logging for tick feed tracking and order book processing, improving traceability of vendor performance during backtests. - Updated database schema to include new fields for managing Optuna study results, enhancing the ability to track study progress and outcomes. - Refactored existing functions to utilize the new logging and tracking features, ensuring consistency across the backtesting framework. Impact: - These enhancements improve the robustness and transparency of the Optuna backtesting process, facilitating better analysis and optimization of trading strategies.
549 lines
23 KiB
Python
549 lines
23 KiB
Python
"""
|
|
kis_trader/strategies/tail_catch.py — 꼬리잡기 전략 (3분봉 망치형 되돌림)
|
|
==========================================================================
|
|
- 신호 생성: ``tail_engine.check_buy_signal_live`` / ``check_sell_signal_live``
|
|
- 주문 실행: ``OrderManager.place(OrderRequest(strategy_id="SHORT", ...))``
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import datetime as dt
|
|
from typing import Dict, List, Optional
|
|
|
|
try:
|
|
from ..engine import tail_engine as te
|
|
except ImportError:
|
|
te = None
|
|
|
|
from ..engine.limit_entry_common import (
|
|
compute_atr_limit_price,
|
|
floor_limit_price_krw,
|
|
is_limit_atr_entry,
|
|
limit_valid_until_bar_key,
|
|
resolve_limit_anchor_price,
|
|
short_entry_mode,
|
|
should_cancel_unfilled_limit,
|
|
tail_limit_params,
|
|
)
|
|
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
|
|
from .base import BaseStrategy, is_live_eod_now
|
|
|
|
|
|
class TailCatchStrategy(BaseStrategy):
|
|
strategy_id = "SHORT"
|
|
loop_min_sleep = 1.5
|
|
loop_max_sleep = 2.5
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.candle_tf = 3 # 3분봉
|
|
self._engine_params: Optional[Dict] = None
|
|
self._pending_limit_orders: Dict[str, Dict] = {}
|
|
self.reload_config()
|
|
|
|
# ------------------------------------------------------------------
|
|
def reload_config(self) -> None:
|
|
# 루프마다 호출 — 병목 시 [RELOAD_PROF] 로 단계 ms 기록
|
|
_rp_t0 = time.perf_counter()
|
|
_rp_last = _rp_t0
|
|
_rp: Dict[str, float] = {}
|
|
|
|
def _rp_mark(stage: str) -> None:
|
|
nonlocal _rp_last
|
|
now = time.perf_counter()
|
|
_rp[stage] = (now - _rp_last) * 1000.0
|
|
_rp_last = now
|
|
|
|
self.min_price = get_env_float("MIN_STOCK_PRICE", 1000.0)
|
|
self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.04)
|
|
self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05)
|
|
self.slot_money = (
|
|
get_env_int("TAIL_SLOT_MONEY", 0)
|
|
or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000)
|
|
)
|
|
_rp_mark("env_basic")
|
|
if te is not None:
|
|
try:
|
|
# ※ get_tail_defaults_from_db → db.get_merged_env_snapshot() 직접
|
|
# (SCALP 는 get_strategy_env_dict RAM 캐시 경로 · SHORT/MOM 은 매 루프 DB)
|
|
p = te.get_tail_defaults_from_db(self.db)
|
|
_rp_mark("tail_defaults")
|
|
p["live_backtest_align"] = get_env_bool(
|
|
"SHORT_LIVE_BACKTEST_ALIGN", True,
|
|
)
|
|
p["live_signal_lookback_bars"] = get_env_int(
|
|
"SHORT_LIVE_SIGNAL_LOOKBACK_BARS", 1,
|
|
)
|
|
p["entry_mode"] = short_entry_mode()
|
|
self._engine_params = p
|
|
_rp_mark("tail_flags")
|
|
except Exception as e:
|
|
self.logger.debug("tail_engine defaults 조회 실패: %s", e)
|
|
_rp_mark("tail_err")
|
|
self.eod_enabled = get_env_bool("TAIL_EOD_ENABLED", True)
|
|
self.eod_hm = get_env_from_db("TAIL_EOD_HM", "15:20")
|
|
_rp_mark("eod")
|
|
|
|
_rp_total = (time.perf_counter() - _rp_t0) * 1000.0
|
|
# 500ms 이상만 — 장중 수 초 reload 원인 확정용
|
|
if _rp_total >= 500.0:
|
|
parts = [f"[RELOAD_PROF] SHORT total={_rp_total:.1f}"]
|
|
for k, v in sorted(_rp.items(), key=lambda x: -x[1]):
|
|
parts.append(f"{k}={v:.1f}")
|
|
line = " ".join(parts)
|
|
try:
|
|
self.logger.info("%s", line)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
path = str(get_env_from_db("LOOP_PROFILE_LOG_PATH", "logs/loop_profile.log") or "").strip()
|
|
if path:
|
|
import os
|
|
from datetime import datetime as _dt
|
|
if not os.path.isabs(path):
|
|
root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
path = os.path.join(root, path)
|
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
with open(path, "a", encoding="utf-8") as f:
|
|
f.write(_dt.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + " " + line + "\n")
|
|
except Exception:
|
|
pass
|
|
|
|
def _candidate_filter(self, candidate: Dict) -> bool:
|
|
"""tail_on 이 True 인 후보만 대상 (SCALP 과 분리)."""
|
|
return bool(candidate.get("tail_on", True))
|
|
|
|
def manage_pending_orders(self) -> None:
|
|
"""ATR 지정가 미체결 — 유효 봉 지나면 취소, 체결 시 DB 반영."""
|
|
if not self._pending_limit_orders:
|
|
return
|
|
from ..execution.order_manager import OrderRequest
|
|
|
|
for code in list(self._pending_limit_orders.keys()):
|
|
pend = self._pending_limit_orders.get(code)
|
|
if not pend:
|
|
continue
|
|
if code in self.holdings:
|
|
self._pending_limit_orders.pop(code, None)
|
|
continue
|
|
req = pend.get("request")
|
|
ord_no = pend.get("ord_no")
|
|
if req and ord_no:
|
|
fin = self.order_mgr.try_finalize_limit_buy(req, ord_no)
|
|
if fin.success and fin.filled_qty > 0:
|
|
self._load_holdings_from_db()
|
|
self._pending_limit_orders.pop(code, None)
|
|
self.logger.info(
|
|
"✅ [지정가체결-반영] %s ODNO=%s", code, ord_no,
|
|
)
|
|
continue
|
|
candles_raw = self.ws.get_candles(code, self.candle_tf, n=30)
|
|
if not candles_raw:
|
|
continue
|
|
candles = [self._norm_candle(c) for c in candles_raw]
|
|
latest_key = str(candles[-1].get("candle_time") or "")[:12]
|
|
vu = str(pend.get("valid_until_bar_key") or "")[:12]
|
|
if not should_cancel_unfilled_limit(latest_key, vu):
|
|
continue
|
|
disp = pend.get("name") or code
|
|
if ord_no and self.order_mgr.client.cancel_order(str(ord_no)):
|
|
self.logger.info(
|
|
"🚫 [지정가취소] %s %s — 유효봉 종료(%s→%s) 미체결",
|
|
disp, code, vu, latest_key,
|
|
)
|
|
else:
|
|
self.logger.info(
|
|
"🚫 [지정가만료] %s — 유효봉 %s 지남 (취소 API 실패 시 HTS 확인)",
|
|
code, vu,
|
|
)
|
|
self._pending_limit_orders.pop(code, None)
|
|
|
|
def on_limit_buy_submitted(self, signal: Dict, result) -> None:
|
|
from ..execution.order_manager import OrderRequest
|
|
|
|
code = signal["code"]
|
|
self._pending_limit_orders[code] = {
|
|
"ord_no": result.ord_no,
|
|
"valid_until_bar_key": signal.get("valid_until_bar_key"),
|
|
"signal_bar_key": signal.get("signal_bar_key"),
|
|
"name": signal.get("name", code),
|
|
"request": OrderRequest(
|
|
strategy_id=self.strategy_id,
|
|
code=code,
|
|
name=signal.get("name", code),
|
|
side="BUY",
|
|
qty=int(signal.get("qty", 0)),
|
|
price_ref=float(signal.get("price", 0)),
|
|
stop_price=float(signal.get("stop_price", 0)),
|
|
target_price=float(signal.get("target_price", 0)),
|
|
atr_entry=float(signal.get("atr_entry", 0)),
|
|
size_class=signal.get("size_class"),
|
|
entry_features=signal.get("entry_features"),
|
|
use_limit_buy=True,
|
|
),
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# 매수
|
|
# ------------------------------------------------------------------
|
|
def check_buy(self, code: str, name: str) -> Optional[Dict]:
|
|
if te is None:
|
|
self.logger.warning("tail_engine 미탑재 → 매수 체크 스킵")
|
|
return None
|
|
# 대형 주도주 등 하락매수 제외 종목 차단 (DIP_BUY_EXCLUDE_CODES 비면 무효)
|
|
if self.is_dip_buy_excluded(code):
|
|
self._scan_log("info", code, "🔍 [탈락-대형주제외] %s %s: DIP_BUY_EXCLUDE_CODES", name, code)
|
|
return None
|
|
_cb = self._cb_prof_start(code)
|
|
try:
|
|
if get_env_bool("FORCE_BUY_TEST", False):
|
|
return self._force_buy_test(code, name)
|
|
|
|
min_len = get_env_int("MIN_CANDLE_LEN_TAIL", 14)
|
|
candles_raw = self.ws.get_candles(code, self.candle_tf, n=50)
|
|
self._cb_prof_mark(_cb, "candles")
|
|
if len(candles_raw) < min_len:
|
|
try:
|
|
self.ws.fill_gap([code], force=True)
|
|
except Exception:
|
|
pass
|
|
self._cb_prof_mark(_cb, "fill_gap")
|
|
return None
|
|
candles = [self._norm_candle(c) for c in candles_raw]
|
|
self._cb_prof_mark(_cb, "norm")
|
|
if len(candles) < 10:
|
|
return None
|
|
|
|
today = dt.now().strftime("%Y%m%d")
|
|
last_exit_dt = None
|
|
if code in self.recently_sold:
|
|
try:
|
|
last_exit_dt = dt.fromtimestamp(self.recently_sold[code])
|
|
if last_exit_dt.strftime("%Y%m%d") != today:
|
|
last_exit_dt = None
|
|
except Exception:
|
|
pass
|
|
try:
|
|
today_trades = self._get_today_trades(today)
|
|
code_trades = [
|
|
t for t in today_trades
|
|
if t.get("code") == code and str(t.get("strategy", "")).startswith("SHORT")
|
|
]
|
|
daily_cnt = len(code_trades)
|
|
daily_pnl_krw = sum(
|
|
float(t.get("realized_pnl") or 0) for t in code_trades
|
|
)
|
|
except Exception:
|
|
daily_cnt = 0
|
|
daily_pnl_krw = 0.0
|
|
self._cb_prof_mark(_cb, "trades_db")
|
|
state = {
|
|
"last_exit_dt": last_exit_dt,
|
|
"daily_cnt": daily_cnt,
|
|
"daily_pnl_krw": daily_pnl_krw,
|
|
}
|
|
|
|
params = dict(self._engine_params or {})
|
|
params["_whipsaw_ws"] = self.ws
|
|
params["_whipsaw_code"] = code
|
|
params["_orderbook_ws"] = self.ws
|
|
params["_orderbook_code"] = code
|
|
params["_program_ws"] = self.ws
|
|
params["_program_code"] = code
|
|
params["slot_money"] = self.slot_money
|
|
reject, msg, sig = te.check_buy_signal_live(candles, params, state)
|
|
self._cb_prof_mark(_cb, "engine")
|
|
if reject:
|
|
self._scan_log("info", code, "🔍 [%s] %s %s: %s", reject, name, code, msg or "")
|
|
return None
|
|
if not sig:
|
|
return None
|
|
|
|
if code in self._pending_limit_orders:
|
|
return None
|
|
|
|
params = self._engine_params or {}
|
|
eng = params if params else te.get_tail_defaults_from_db(self.db)
|
|
atr_period = int(eng.get("atr_period", 14))
|
|
atr_series = te.compute_atr_series(candles, atr_period)
|
|
self._cb_prof_mark(_cb, "atr")
|
|
|
|
if is_limit_atr_entry(short_entry_mode(eng)):
|
|
if len(candles) < 2:
|
|
return None
|
|
sig_i = len(candles) - 2
|
|
sig_bar = candles[sig_i]
|
|
lp_cfg = tail_limit_params(eng)
|
|
anchor_px = resolve_limit_anchor_price(
|
|
lp_cfg["anchor"], sig_bar, candles, sig_i,
|
|
)
|
|
atr_val = atr_series[sig_i] if sig_i < len(atr_series) else None
|
|
limit_px = compute_atr_limit_price(
|
|
anchor_px, atr_val, lp_cfg["mult"], min_price=self.min_price,
|
|
)
|
|
limit_int = floor_limit_price_krw(limit_px)
|
|
if limit_int <= 0:
|
|
return None
|
|
stop_price, target_price = te.compute_tail_atr_prices(
|
|
float(limit_int), float(atr_val or limit_int * 0.01), eng,
|
|
)
|
|
valid_until = limit_valid_until_bar_key(
|
|
candles, sig_i, lp_cfg["valid_bars"],
|
|
)
|
|
hard_cap = get_env_int("SHORT_MAX_BUY_AMOUNT", 0) \
|
|
or get_env_int("TAIL_MAX_BUY_AMOUNT", 0) \
|
|
or get_env_int("MAX_BUY_AMOUNT_PER_STOCK", 0)
|
|
qty, rej = self._resolve_buy_qty_live(
|
|
float(limit_int), hard_cap=hard_cap,
|
|
)
|
|
self._cb_prof_mark(_cb, "qty")
|
|
if rej:
|
|
self.logger.info(
|
|
"🔍 [탈락-%s] %s(%s) limit=%s",
|
|
rej, name, code, f"{limit_int:,}",
|
|
)
|
|
return None
|
|
self.logger.info(
|
|
"🎯 [SHORT 지정가] %s(%s) limit=%s원 유효~%s qty=%d",
|
|
name, code, f"{limit_int:,}", valid_until, qty,
|
|
)
|
|
return {
|
|
"code": code,
|
|
"name": name,
|
|
"price": float(limit_int),
|
|
"qty": qty,
|
|
"use_limit_buy": True,
|
|
"valid_until_bar_key": valid_until,
|
|
"signal_bar_key": str(sig_bar.get("candle_time") or "")[:12],
|
|
"stop_price": stop_price,
|
|
"target_price": target_price,
|
|
"atr_entry": float(atr_val or 0),
|
|
"session_low": float(limit_int),
|
|
"max_price": float(limit_int),
|
|
"size_class": "",
|
|
"entry_features": {
|
|
"rsi": sig.get("rsi_val", 50),
|
|
"tail_length_pct": sig.get("tail_pct", 0) * 100,
|
|
"entry_mode": "limit_atr",
|
|
},
|
|
}
|
|
|
|
# align — 다음 3분봉: RAM 첫 틱 → 없으면 시가 (시장가 계산용)
|
|
align_on = get_env_bool("SHORT_LIVE_BACKTEST_ALIGN", True)
|
|
entry_open = float(sig.get("entry_price", 0) or 0)
|
|
entry_src = "ohlc_open"
|
|
_ebk = str(sig.get("entry_bar_key") or "")[:12]
|
|
_defer = self._defer_mid_enroll_entry(
|
|
code, _ebk, int(self.candle_tf or 3),
|
|
)
|
|
self._cb_prof_mark(_cb, "mid_enroll")
|
|
if _defer:
|
|
self._scan_log("info", code, "🔍 [%s] %s(%s)", _defer, name, code)
|
|
return None
|
|
if align_on and entry_open > 0:
|
|
from kis_trader.engine.tail_tick_replay import live_align_entry_price
|
|
curr_price, entry_src = live_align_entry_price(
|
|
self.ws,
|
|
code,
|
|
entry_open,
|
|
entry_bar_key=_ebk,
|
|
tf_min=int(self.candle_tf or 3),
|
|
)
|
|
else:
|
|
curr_price = float(candles[-1]["close"])
|
|
wsd = self._ws_last_quote(code)
|
|
if wsd:
|
|
try:
|
|
curr_price = abs(
|
|
float(str(wsd.get("stck_prpr", curr_price)).replace(",", ""))
|
|
) or curr_price
|
|
except Exception:
|
|
pass
|
|
self._cb_prof_mark(_cb, "align")
|
|
if curr_price <= 0 or curr_price < self.min_price:
|
|
return None
|
|
|
|
hard_cap = get_env_int("SHORT_MAX_BUY_AMOUNT", 0) \
|
|
or get_env_int("TAIL_MAX_BUY_AMOUNT", 0) \
|
|
or get_env_int("MAX_BUY_AMOUNT_PER_STOCK", 0)
|
|
qty, rej = self._resolve_buy_qty_live(
|
|
curr_price, hard_cap=hard_cap,
|
|
)
|
|
self._cb_prof_mark(_cb, "qty")
|
|
if rej:
|
|
self.logger.info(
|
|
"🔍 [탈락-%s] %s(%s) price=%.0f",
|
|
rej, name, code, curr_price,
|
|
)
|
|
return None
|
|
|
|
stop_price = curr_price * (1 + self.stop_loss_pct)
|
|
target_price = curr_price * (1 + self.take_profit_pct)
|
|
atr_entry = 0.0
|
|
try:
|
|
atr_val = atr_series[-1] if atr_series else None
|
|
if atr_val is not None and float(atr_val) > 0:
|
|
atr_entry = float(atr_val)
|
|
stop_price, target_price = te.compute_tail_atr_prices(
|
|
curr_price, atr_entry, eng,
|
|
)
|
|
self.logger.info(
|
|
"📊 [SHORT ATR] %s(%s) ATR=%.0f 손절=%.0f 목표=%.0f",
|
|
name, code, atr_entry, stop_price, target_price,
|
|
)
|
|
except Exception as e:
|
|
self.logger.debug("SHORT ATR 손절/목표 계산 스킵(%s): %s", code, e)
|
|
if atr_entry <= 0:
|
|
atr_entry = curr_price * 0.01
|
|
|
|
self.logger.info(
|
|
"🎯 [SHORT 시그널] %s(%s) price=%.0f qty=%d pat=%s tail=%.2f rec=%.0f%% RSI=%.1f entry_src=%s",
|
|
name, code, curr_price, qty,
|
|
sig.get("pattern", "hammer"),
|
|
sig.get("tail_ratio", 0), sig.get("recovery_pos", 0) * 100, sig.get("rsi_val", 0),
|
|
entry_src,
|
|
)
|
|
return {
|
|
"code": code,
|
|
"name": name,
|
|
"price": curr_price,
|
|
"qty": qty,
|
|
"stop_price": stop_price,
|
|
"target_price": target_price,
|
|
"atr_entry": atr_entry if atr_entry > 0 else float(sig.get("atr_calc_val") or 0.0),
|
|
"session_low": curr_price,
|
|
"max_price": curr_price,
|
|
"size_class": "",
|
|
"entry_features": {
|
|
"rsi": sig.get("rsi_val", 50),
|
|
"tail_length_pct": sig.get("tail_pct", 0) * 100,
|
|
"pattern": sig.get("pattern", "hammer"),
|
|
},
|
|
}
|
|
except Exception as e:
|
|
self._scan_log("info", code, "🔍 [탈락-예외] %s %s: %s", name, code, e)
|
|
return None
|
|
finally:
|
|
self._cb_prof_finish(_cb)
|
|
|
|
def _force_buy_test(self, code: str, name: str) -> Optional[Dict]:
|
|
wsd = self._ws_last_quote(code)
|
|
px = 0.0
|
|
if wsd:
|
|
try:
|
|
px = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
|
|
except Exception:
|
|
px = 0.0
|
|
if px <= 0:
|
|
pd_ = self.client.inquire_price(code)
|
|
if pd_:
|
|
try:
|
|
px = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", "")))
|
|
except Exception:
|
|
px = 0.0
|
|
if px <= 0:
|
|
return None
|
|
qty = max(1, int(self.slot_money / px))
|
|
return {
|
|
"code": code,
|
|
"name": name,
|
|
"price": px,
|
|
"qty": qty,
|
|
"stop_price": px * (1 + self.stop_loss_pct),
|
|
"target_price": px * (1 + self.take_profit_pct),
|
|
"atr_entry": 0.0,
|
|
"size_class": "",
|
|
"entry_features": {},
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# 매도
|
|
# ------------------------------------------------------------------
|
|
def check_sell_signals(self) -> List[Dict]:
|
|
if not self.holdings or te is None:
|
|
return []
|
|
|
|
now = dt.now()
|
|
is_eod = is_live_eod_now(
|
|
getattr(self, "eod_enabled", True),
|
|
getattr(self, "eod_hm", "15:20"),
|
|
now,
|
|
default_hm="15:20",
|
|
)
|
|
try:
|
|
params = te.get_tail_defaults_from_db(self.db)
|
|
except Exception:
|
|
params = {}
|
|
|
|
signals: List[Dict] = []
|
|
for code, holding in list(self.holdings.items()):
|
|
try:
|
|
name = holding.get("name", code)
|
|
buy_price = float(holding.get("buy_price", 0))
|
|
qty = int(holding.get("qty", 0))
|
|
if qty <= 0 or buy_price <= 0:
|
|
continue
|
|
|
|
current_price = self._resolve_sell_price(
|
|
code, is_eod=is_eod, buy_price=buy_price,
|
|
)
|
|
if current_price <= 0:
|
|
continue
|
|
|
|
max_price = float(holding.get("max_price") or buy_price)
|
|
if current_price > max_price:
|
|
max_price = current_price
|
|
holding["max_price"] = max_price
|
|
|
|
session_low = float(holding.get("session_low") or buy_price)
|
|
if current_price < session_low:
|
|
session_low = current_price
|
|
holding["session_low"] = session_low
|
|
|
|
position = {
|
|
"entry_price": buy_price,
|
|
"entry_time": holding.get("buy_time", ""),
|
|
"qty": qty,
|
|
"stop": float(holding.get("stop_price", buy_price * (1 + self.stop_loss_pct))),
|
|
"target": float(holding.get("target_price", buy_price * (1 + self.take_profit_pct))),
|
|
"max_price": max_price,
|
|
}
|
|
candle = {
|
|
"high": max_price,
|
|
"low": session_low,
|
|
"close": current_price,
|
|
"candle_time": now.strftime("%Y%m%d%H%M"),
|
|
}
|
|
res = te.check_sell_signal_live(position, candle, params, is_eod=is_eod)
|
|
if not res:
|
|
continue
|
|
reason, exit_price = res
|
|
profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0
|
|
signals.append({
|
|
"code": code,
|
|
"name": name,
|
|
"current_price": current_price,
|
|
"price": exit_price,
|
|
"qty": qty,
|
|
"buy_price": buy_price,
|
|
"profit_pct": profit_pct,
|
|
"reason": reason,
|
|
})
|
|
except Exception as e:
|
|
self.logger.error("매도 시그널 체크 오류(%s): %s", code, e)
|
|
return signals
|
|
|
|
# ------------------------------------------------------------------
|
|
def _norm_candle(self, c: dict) -> dict:
|
|
ct = c.get("candle_time") or c.get("candle_time_str", "")
|
|
if isinstance(ct, str) and len(ct) == 19 and " " in ct:
|
|
ct = ct.replace("-", "").replace(" ", "").replace(":", "")[:12]
|
|
return {
|
|
"candle_time": ct,
|
|
"open": float(c.get("open", 0)),
|
|
"high": float(c.get("high", 0)),
|
|
"low": float(c.get("low", 0)),
|
|
"close": float(c.get("close", 0)),
|
|
"volume": float(c.get("volume", 0)),
|
|
}
|