feat: Add DART strategy and related configurations

ㅇ
Changes:
- Introduced the DART strategy to the trading system, including its configuration and integration into the existing framework.
- Updated the database schema to include DART-specific tables for disclosures and watchlists.
- Enhanced the backtesting and parameter search functionalities to support the DART strategy.
- Implemented new rules for browser verification and API interactions to ensure compliance with the updated DART strategy.

Impact:
- These additions expand the trading capabilities of the system, allowing for more comprehensive analysis and execution of DART-related strategies, while maintaining system integrity and performance.
This commit is contained in:
Your Name
2026-07-21 07:50:24 +09:00
parent 74db49149f
commit 61bec4bd1d
86 changed files with 4811 additions and 297 deletions

View File

@@ -40,9 +40,11 @@ from kis_trader.engine.momentum_tick_replay import (
momentum_backtest_scan_sec,
momentum_backtest_skip_pre_subscribe,
momentum_backtest_use_tick_exit,
momentum_backtest_wallclock_last_price,
momentum_live_align_enabled,
resolve_momentum_sell_for_bar,
try_momentum_sell_on_ticks,
update_momentum_bt_last_px,
)
from kis_trader.backtest.momentum_tick_loader import entry_before_first_tick
from kis_trader.backtest.momentum_universe_timeline import (
@@ -149,6 +151,8 @@ def _try_open_momentum_position(
"target": pe["target"],
"max_price": entry_price,
"rsi": pe.get("rsi"),
"_bt_last_px": entry_price,
"_bt_last_px_t": str(entry_time or "")[:12],
}
src = str(pe.get("entry_source") or "ohlc_open")
if src == "ws_ticks":
@@ -215,7 +219,8 @@ def _record_momentum_sell(
all_trades: List[Dict],
tick_exit_count: int,
ohlc_exit_count: int,
) -> Tuple[int, int]:
wallclock_exit_count: int = 0,
) -> Tuple[int, int, int]:
trade: Dict[str, Any] = {
"code": code,
"buy_time": pos["entry_time"],
@@ -237,9 +242,11 @@ def _record_momentum_sell(
del portfolio[code]
if exit_source == "ws_ticks":
tick_exit_count += 1
elif exit_source == "wallclock_last":
wallclock_exit_count += 1
else:
ohlc_exit_count += 1
return tick_exit_count, ohlc_exit_count
return tick_exit_count, ohlc_exit_count, wallclock_exit_count
def _process_sells_for_scan(
@@ -252,29 +259,68 @@ def _process_sells_for_scan(
all_trades: List[Dict],
tick_exit_count: int,
ohlc_exit_count: int,
wallclock_exit_count: int,
scan_sec: int,
) -> Tuple[int, int]:
"""스캔 시각까지 틱·OHLC 청산 (실매 루프: 매도 먼저)."""
) -> Tuple[int, int, int]:
"""스캔 시각까지 틱·벽시계 last 청산 (실매 루프: 매도 먼저)."""
bar_t = scan_key[:12]
is_eod = is_strategy_eod_bar(bar_t, params, "MOMENTUM")
use_wall = momentum_backtest_wallclock_last_price(params)
for code in list(portfolio.keys()):
ctx = ctx_by_code.get(code)
if ctx is None:
continue
idx = ctx["time_index"].get(bar_t)
if idx is None:
continue
candles = ctx["candles"]
c = candles[idx]
c = candles[idx] if idx is not None else None
pos = portfolio[code]
if str(pos.get("entry_time") or "")[:12] == bar_t:
continue
entry_time = str(pos.get("entry_time") or "")
sold = False
minute_ticks = []
if momentum_backtest_use_tick_exit(params) and ticks_by_code:
minute_ticks = collect_minute_ticks(ticks_by_code, code, bar_t)
# 공유메모리 컬럼 뷰면 dict 재구성 없이 뷰 캡핑(동일 문자열 비교). 아니면 기존 리스트 캡핑.
if minute_ticks:
try:
from kis_trader.backtest.shared_ticks import TickColumnView
_is_view = isinstance(minute_ticks, TickColumnView)
except Exception:
_is_view = False
if _is_view:
capped = minute_ticks.cap_by_tick_time_le(scan_key[:14])
else:
capped = [
tk for tk in minute_ticks
if str(tk.get("tick_time") or "")[:14] <= scan_key[:14]
]
if capped:
if _is_view:
last_i = None
for i in capped.iter_idx():
last_i = i
if last_i is not None:
update_momentum_bt_last_px(
pos, float(capped.owner._price[last_i]), bar_t,
)
else:
for tk in reversed(capped):
try:
px = float(tk.get("price") or 0)
except (TypeError, ValueError):
continue
if px > 0:
update_momentum_bt_last_px(pos, px, bar_t)
break
elif c is not None:
try:
update_momentum_bt_last_px(pos, float(c["close"]), bar_t)
except (TypeError, ValueError, KeyError):
pass
sold = False
if minute_ticks:
try:
from kis_trader.backtest.shared_ticks import TickColumnView
_is_view = isinstance(minute_ticks, TickColumnView)
@@ -293,12 +339,13 @@ def _process_sells_for_scan(
)
if tick_res:
reason, fill_px, sell_time, hold_min = tick_res
tick_exit_count, ohlc_exit_count = _record_momentum_sell(
tick_exit_count, ohlc_exit_count, wallclock_exit_count = _record_momentum_sell(
portfolio=portfolio, code=code, ctx=ctx, pos=pos,
reason=reason, exit_price=fill_px, sell_time_key=sell_time,
hold_min=hold_min, exit_source="ws_ticks",
all_trades=all_trades,
tick_exit_count=tick_exit_count, ohlc_exit_count=ohlc_exit_count,
wallclock_exit_count=wallclock_exit_count,
)
sold = True
@@ -308,13 +355,28 @@ def _process_sells_for_scan(
if not _is_minute_tail_scan(scan_key, scan_sec):
continue
cur_c_info = {
"open": float(c["open"]),
"high": float(c["high"]),
"low": float(c["low"]),
"close": float(c["close"]),
"candle_time": bar_t,
}
if c is None and not (use_wall and pos.get("_bt_last_px")):
continue
if c is not None:
cur_c_info = {
"open": float(c["open"]),
"high": float(c["high"]),
"low": float(c["low"]),
"close": float(c["close"]),
"candle_time": bar_t,
}
else:
last_px = float(pos.get("_bt_last_px") or 0)
if last_px <= 0:
continue
cur_c_info = {
"open": last_px,
"high": last_px,
"low": last_px,
"close": last_px,
"candle_time": bar_t,
}
sell_res = resolve_momentum_sell_for_bar(
pos, cur_c_info, params,
is_eod=is_eod,
@@ -324,14 +386,15 @@ def _process_sells_for_scan(
if not sell_res:
continue
reason, exit_price, sell_time_key, hold_min, exit_source = sell_res
tick_exit_count, ohlc_exit_count = _record_momentum_sell(
tick_exit_count, ohlc_exit_count, wallclock_exit_count = _record_momentum_sell(
portfolio=portfolio, code=code, ctx=ctx, pos=pos,
reason=reason, exit_price=exit_price, sell_time_key=sell_time_key,
hold_min=hold_min, exit_source=exit_source,
all_trades=all_trades,
tick_exit_count=tick_exit_count, ohlc_exit_count=ohlc_exit_count,
wallclock_exit_count=wallclock_exit_count,
)
return tick_exit_count, ohlc_exit_count
return tick_exit_count, ohlc_exit_count, wallclock_exit_count
def _universe_codes_for_scan(
@@ -522,6 +585,7 @@ def run_momentum_backtest_portfolio(
skipped_micro_buys = 0
tick_exit_count = 0
ohlc_exit_count = 0
wallclock_exit_count = 0
entry_stats: Dict[str, int] = {}
live_align = momentum_live_align_enabled(params)
live_scan_queue = momentum_backtest_live_scan_queue_enabled(params)
@@ -589,13 +653,14 @@ def run_momentum_backtest_portfolio(
tp_pct = effective_tp_pct_from_params(params)
slot_key = _slot_key(bar_t, int(params.get("scan_interval_min", 1)))
tick_exit_count, ohlc_exit_count = _process_sells_for_scan(
tick_exit_count, ohlc_exit_count, wallclock_exit_count = _process_sells_for_scan(
portfolio, ctx_by_code, scan_key,
params=params,
ticks_by_code=ticks_by_code,
all_trades=all_trades,
tick_exit_count=tick_exit_count,
ohlc_exit_count=ohlc_exit_count,
wallclock_exit_count=wallclock_exit_count,
scan_sec=scan_sec,
)
@@ -700,22 +765,41 @@ def run_momentum_backtest_portfolio(
if ctx is None:
continue
idx = ctx["time_index"].get(t)
if idx is None:
continue
candles = ctx["candles"]
c = candles[idx]
day = t[:8]
if str(portfolio[code]["entry_time"])[:12] == str(t)[:12]:
c = candles[idx] if idx is not None else None
pos = portfolio[code]
if str(pos.get("entry_time") or "")[:12] == str(t)[:12]:
continue
if c is not None:
try:
update_momentum_bt_last_px(pos, float(c["close"]), t)
except (TypeError, ValueError, KeyError):
pass
elif not (
momentum_backtest_wallclock_last_price(params)
and pos.get("_bt_last_px")
):
continue
is_eod = is_strategy_eod_bar(t, params, "MOMENTUM")
cur_c_info = {
"open": float(c["open"]),
"high": float(c["high"]),
"low": float(c["low"]),
"close": float(c["close"]),
"candle_time": t,
}
pos = portfolio[code]
if c is not None:
cur_c_info = {
"open": float(c["open"]),
"high": float(c["high"]),
"low": float(c["low"]),
"close": float(c["close"]),
"candle_time": t,
}
else:
last_px = float(pos.get("_bt_last_px") or 0)
if last_px <= 0:
continue
cur_c_info = {
"open": last_px,
"high": last_px,
"low": last_px,
"close": last_px,
"candle_time": t,
}
sell_res = resolve_momentum_sell_for_bar(
pos, cur_c_info, params,
is_eod=is_eod,
@@ -725,12 +809,13 @@ def run_momentum_backtest_portfolio(
if not sell_res:
continue
reason, exit_price, sell_time_key, hold_min, exit_source = sell_res
tick_exit_count, ohlc_exit_count = _record_momentum_sell(
tick_exit_count, ohlc_exit_count, wallclock_exit_count = _record_momentum_sell(
portfolio=portfolio, code=code, ctx=ctx, pos=pos,
reason=reason, exit_price=exit_price, sell_time_key=sell_time_key,
hold_min=hold_min, exit_source=exit_source,
all_trades=all_trades,
tick_exit_count=tick_exit_count, ohlc_exit_count=ohlc_exit_count,
wallclock_exit_count=wallclock_exit_count,
)
if len(portfolio) >= max_stocks:
@@ -776,9 +861,10 @@ def run_momentum_backtest_portfolio(
skip_stats: Dict[str, Any] = {}
if skipped_micro_buys:
skip_stats["skipped_micro_buys"] = skipped_micro_buys
if tick_exit_count or ohlc_exit_count:
if tick_exit_count or ohlc_exit_count or wallclock_exit_count:
skip_stats["tick_exit_count"] = tick_exit_count
skip_stats["ohlc_exit_count"] = ohlc_exit_count
skip_stats["wallclock_exit_count"] = wallclock_exit_count
if entry_stats:
skip_stats.update(entry_stats)
if live_scan_queue and live_align: