Compare commits
27 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ea41cf3c | ||
| af5bfbac24 | |||
|
|
7e9a573390 | ||
| 7dbc48260c | |||
|
|
4b883a4fc4 | ||
|
|
98071a8ee3 | ||
|
|
f2ad270e8b | ||
| 04c73a1a06 | |||
|
|
4da22b10eb | ||
| c920b257b6 | |||
| 9927bfa13e | |||
|
|
aceba86186 | ||
|
|
b961c53a92 | ||
| 76a7ee7cdb | |||
|
|
77577f3f4d | ||
| 17112b864a | |||
|
|
28bcc7acd7 | ||
|
|
39b9f179f4 | ||
| bd2b3241b2 | |||
| 561faaaafa | |||
| a33d6a145f | |||
| 7e6c912214 | |||
|
|
d6edbc0fa2 | ||
|
|
c7640a30d7 | ||
|
|
60a22d6cd4 | ||
|
|
b1f48d859e | ||
| 03f8d220a4 |
@@ -192,6 +192,27 @@ When `TELEGRAM_COMMANDS_ENABLED=true` (default), the bot accepts these interacti
|
|||||||
|
|
||||||
Commands are only processed from the authorized `TELEGRAM_CHAT_ID`.
|
Commands are only processed from the authorized `TELEGRAM_CHAT_ID`.
|
||||||
|
|
||||||
|
## KIS API TR_ID 참조 문서
|
||||||
|
|
||||||
|
**TR_ID를 추가하거나 수정할 때 반드시 공식 문서를 먼저 확인할 것.**
|
||||||
|
|
||||||
|
공식 문서: `docs/한국투자증권_오픈API_전체문서_20260221_030000.xlsx`
|
||||||
|
|
||||||
|
> ⚠️ 커뮤니티 블로그, GitHub 예제 등 비공식 자료의 TR_ID는 오래되거나 틀릴 수 있음.
|
||||||
|
> 실제로 `VTTT1006U`(미국 매도 — 잘못됨)가 오랫동안 코드에 남아있던 사례가 있음 (Issue #189).
|
||||||
|
|
||||||
|
### 주요 TR_ID 목록
|
||||||
|
|
||||||
|
| 구분 | 모의투자 TR_ID | 실전투자 TR_ID | 시트명 |
|
||||||
|
|------|---------------|---------------|--------|
|
||||||
|
| 해외주식 매수 (미국) | `VTTT1002U` | `TTTT1002U` | 해외주식 주문 |
|
||||||
|
| 해외주식 매도 (미국) | `VTTT1001U` | `TTTT1006U` | 해외주식 주문 |
|
||||||
|
|
||||||
|
새로운 TR_ID가 필요할 때:
|
||||||
|
1. 위 xlsx 파일에서 해당 거래 유형의 시트를 찾는다.
|
||||||
|
2. 모의투자(`VTTT`) / 실전투자(`TTTT`) 컬럼을 구분하여 정확한 값을 사용한다.
|
||||||
|
3. 코드에 출처 주석을 남긴다: `# Source: 한국투자증권_오픈API_전체문서 — '<시트명>' 시트`
|
||||||
|
|
||||||
## Environment Setup
|
## Environment Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -7,6 +7,32 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-02-21
|
||||||
|
|
||||||
|
### 거래 상태 확인 중 발견된 버그 (#187)
|
||||||
|
|
||||||
|
- 거래 상태 점검 요청 → SELL 주문(손절/익절)이 Fat Finger에 막혀 전혀 실행 안 됨 발견
|
||||||
|
- **#187 (Critical)**: SELL 주문에서 Fat Finger 오탐 — `order_amount/total_cash > 30%`가 SELL에도 적용되어 대형 포지션 매도 불가
|
||||||
|
- JELD stop-loss -6.20% → 차단, RXT take-profit +46.13% → 차단
|
||||||
|
- 수정: SELL은 `check_circuit_breaker`만 호출, `validate_order`(Fat Finger 포함) 미호출
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-02-20
|
||||||
|
|
||||||
|
### 지속적 모니터링 및 개선점 도출 (이슈 #178~#182)
|
||||||
|
|
||||||
|
- Dashboard 포함해서 실행하며 간헐적 문제 모니터링 및 개선점 자동 도출 요청
|
||||||
|
- 모니터링 결과 발견된 이슈 목록:
|
||||||
|
- **#178**: uvicorn 미설치 → dashboard 미작동 + 오해의 소지 있는 시작 로그 → uvicorn 설치 완료
|
||||||
|
- **#179 (Critical)**: 잔액 부족 주문 실패 후 매 사이클마다 무한 재시도 (MLECW 20분 이상 반복)
|
||||||
|
- **#180**: 다중 인스턴스 실행 시 Telegram 409 충돌
|
||||||
|
- **#181**: implied_rsi 공식 포화 문제 (change_rate≥12.5% → RSI=100)
|
||||||
|
- **#182 (Critical)**: 보유 종목이 SmartScanner 변동성 필터에 걸려 SELL 신호 미생성 → SELL 체결 0건, 잔고 소진
|
||||||
|
- 요구사항: 모니터링 자동화 및 주기적 개선점 리포트 도출
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-02-05
|
## 2026-02-05
|
||||||
|
|
||||||
### API 효율화
|
### API 효율화
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class SmartVolatilityScanner:
|
|||||||
liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
|
liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
|
||||||
score = min(100.0, volatility_score + liquidity_score)
|
score = min(100.0, volatility_score + liquidity_score)
|
||||||
signal = "momentum" if change_rate >= 0 else "oversold"
|
signal = "momentum" if change_rate >= 0 else "oversold"
|
||||||
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 4.0)))
|
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
|
||||||
|
|
||||||
candidates.append(
|
candidates.append(
|
||||||
ScanCandidate(
|
ScanCandidate(
|
||||||
@@ -282,7 +282,7 @@ class SmartVolatilityScanner:
|
|||||||
liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
|
liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
|
||||||
score = min(100.0, volatility_score + liquidity_score)
|
score = min(100.0, volatility_score + liquidity_score)
|
||||||
signal = "momentum" if change_rate >= 0 else "oversold"
|
signal = "momentum" if change_rate >= 0 else "oversold"
|
||||||
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 4.0)))
|
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
|
||||||
candidates.append(
|
candidates.append(
|
||||||
ScanCandidate(
|
ScanCandidate(
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
@@ -338,7 +338,7 @@ class SmartVolatilityScanner:
|
|||||||
|
|
||||||
score = min(volatility_pct / 10.0, 1.0) * 100.0
|
score = min(volatility_pct / 10.0, 1.0) * 100.0
|
||||||
signal = "momentum" if change_rate >= 0 else "oversold"
|
signal = "momentum" if change_rate >= 0 else "oversold"
|
||||||
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 4.0)))
|
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
|
||||||
candidates.append(
|
candidates.append(
|
||||||
ScanCandidate(
|
ScanCandidate(
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
|
|||||||
@@ -230,7 +230,9 @@ class OverseasBroker:
|
|||||||
session = self._broker._get_session()
|
session = self._broker._get_session()
|
||||||
|
|
||||||
# Virtual trading TR_IDs for overseas orders
|
# Virtual trading TR_IDs for overseas orders
|
||||||
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1006U"
|
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 주문' 시트
|
||||||
|
# VTTT1002U: 모의투자 미국 매수, VTTT1001U: 모의투자 미국 매도
|
||||||
|
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1001U"
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
"CANO": self._broker._account_no,
|
"CANO": self._broker._account_no,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -341,12 +341,68 @@ def create_dashboard_app(db_path: str) -> FastAPI:
|
|||||||
)
|
)
|
||||||
return {"market": market, "date": date_str, "count": len(matches), "matches": matches}
|
return {"market": market, "date": date_str, "count": len(matches), "matches": matches}
|
||||||
|
|
||||||
|
@app.get("/api/positions")
|
||||||
|
def get_positions() -> dict[str, Any]:
|
||||||
|
"""Return all currently open positions (last trade per symbol is BUY)."""
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT stock_code, market, exchange_code,
|
||||||
|
price AS entry_price, quantity, timestamp AS entry_time,
|
||||||
|
decision_id
|
||||||
|
FROM (
|
||||||
|
SELECT stock_code, market, exchange_code, price, quantity,
|
||||||
|
timestamp, decision_id, action,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY stock_code, market
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
) AS rn
|
||||||
|
FROM trades
|
||||||
|
)
|
||||||
|
WHERE rn = 1 AND action = 'BUY'
|
||||||
|
ORDER BY entry_time DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
positions = []
|
||||||
|
for row in rows:
|
||||||
|
entry_time_str = row["entry_time"]
|
||||||
|
try:
|
||||||
|
entry_dt = datetime.fromisoformat(entry_time_str.replace("Z", "+00:00"))
|
||||||
|
held_seconds = int((now - entry_dt).total_seconds())
|
||||||
|
held_hours = held_seconds // 3600
|
||||||
|
held_minutes = (held_seconds % 3600) // 60
|
||||||
|
if held_hours >= 1:
|
||||||
|
held_display = f"{held_hours}h {held_minutes}m"
|
||||||
|
else:
|
||||||
|
held_display = f"{held_minutes}m"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
held_display = "--"
|
||||||
|
|
||||||
|
positions.append(
|
||||||
|
{
|
||||||
|
"stock_code": row["stock_code"],
|
||||||
|
"market": row["market"],
|
||||||
|
"exchange_code": row["exchange_code"],
|
||||||
|
"entry_price": row["entry_price"],
|
||||||
|
"quantity": row["quantity"],
|
||||||
|
"entry_time": entry_time_str,
|
||||||
|
"held": held_display,
|
||||||
|
"decision_id": row["decision_id"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"count": len(positions), "positions": positions}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def _connect(db_path: str) -> sqlite3.Connection:
|
def _connect(db_path: str) -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=8000")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,32 @@
|
|||||||
.rationale-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
|
.rationale-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
|
||||||
.empty-row td { text-align: center; color: var(--muted); padding: 24px; }
|
.empty-row td { text-align: center; color: var(--muted); padding: 24px; }
|
||||||
|
|
||||||
|
/* Positions panel */
|
||||||
|
.positions-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.positions-table { width: 100%; border-collapse: collapse; margin-top: 14px; }
|
||||||
|
.positions-table th {
|
||||||
|
text-align: left; color: var(--muted); font-size: 11px; font-weight: 600;
|
||||||
|
padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.positions-table td {
|
||||||
|
padding: 8px 8px; border-bottom: 1px solid rgba(40, 69, 95, 0.5);
|
||||||
|
vertical-align: middle; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.positions-table tr:last-child td { border-bottom: none; }
|
||||||
|
.positions-table tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
|
.pos-empty { color: var(--muted); text-align: center; padding: 20px 0; font-size: 12px; }
|
||||||
|
.pos-count {
|
||||||
|
display: inline-block; background: rgba(60, 179, 113, 0.12);
|
||||||
|
color: var(--accent); font-size: 11px; font-weight: 700;
|
||||||
|
padding: 2px 8px; border-radius: 10px; margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Spinner */
|
/* Spinner */
|
||||||
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
@@ -163,6 +189,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Open Positions -->
|
||||||
|
<div class="positions-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">
|
||||||
|
현재 보유 포지션
|
||||||
|
<span class="pos-count" id="positions-count">0</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table class="positions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>종목</th>
|
||||||
|
<th>시장</th>
|
||||||
|
<th>수량</th>
|
||||||
|
<th>진입가</th>
|
||||||
|
<th>보유 시간</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="positions-body">
|
||||||
|
<tr><td colspan="5" class="pos-empty"><span class="spinner"></span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- P&L Chart -->
|
<!-- P&L Chart -->
|
||||||
<div class="chart-panel">
|
<div class="chart-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@@ -242,6 +292,39 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtPrice(v, market) {
|
||||||
|
if (v === null || v === undefined) return '--';
|
||||||
|
const n = parseFloat(v);
|
||||||
|
const sym = market === 'KR' ? '₩' : market === 'JP' ? '¥' : market === 'HK' ? 'HK$' : '$';
|
||||||
|
return sym + n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPositions() {
|
||||||
|
const tbody = document.getElementById('positions-body');
|
||||||
|
const countEl = document.getElementById('positions-count');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/positions');
|
||||||
|
if (!r.ok) throw new Error('fetch failed');
|
||||||
|
const d = await r.json();
|
||||||
|
countEl.textContent = d.count ?? 0;
|
||||||
|
if (!d.positions || d.positions.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="pos-empty">현재 보유 중인 포지션 없음</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = d.positions.map(p => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${p.stock_code || '--'}</strong></td>
|
||||||
|
<td><span style="color:var(--muted);font-size:11px">${p.market || '--'}</span></td>
|
||||||
|
<td>${p.quantity ?? '--'}</td>
|
||||||
|
<td>${fmtPrice(p.entry_price, p.market)}</td>
|
||||||
|
<td style="color:var(--muted);font-size:11px">${p.held || '--'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="pos-empty">데이터 로드 실패</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchStatus() {
|
async function fetchStatus() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/status');
|
const r = await fetch('/api/status');
|
||||||
@@ -383,6 +466,7 @@
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchStatus(),
|
fetchStatus(),
|
||||||
fetchPerformance(),
|
fetchPerformance(),
|
||||||
|
fetchPositions(),
|
||||||
fetchPnlHistory(currentDays),
|
fetchPnlHistory(currentDays),
|
||||||
fetchDecisions(currentMarket),
|
fetchDecisions(currentMarket),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -131,6 +131,13 @@ def init_db(db_path: str) -> sqlite3.Connection:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_decision_logs_confidence ON decision_logs(confidence)"
|
"CREATE INDEX IF NOT EXISTS idx_decision_logs_confidence ON decision_logs(confidence)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Index for open-position queries (partition by stock_code, market, ordered by timestamp)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_trades_stock_market_ts"
|
||||||
|
" ON trades (stock_code, market, timestamp DESC)"
|
||||||
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|||||||
187
src/main.py
187
src/main.py
@@ -42,7 +42,7 @@ from src.logging.decision_logger import DecisionLogger
|
|||||||
from src.logging_config import setup_logging
|
from src.logging_config import setup_logging
|
||||||
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
|
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
|
||||||
from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler
|
from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler
|
||||||
from src.strategy.models import DayPlaybook
|
from src.strategy.models import DayPlaybook, MarketOutlook
|
||||||
from src.strategy.playbook_store import PlaybookStore
|
from src.strategy.playbook_store import PlaybookStore
|
||||||
from src.strategy.pre_market_planner import PreMarketPlanner
|
from src.strategy.pre_market_planner import PreMarketPlanner
|
||||||
from src.strategy.scenario_engine import ScenarioEngine
|
from src.strategy.scenario_engine import ScenarioEngine
|
||||||
@@ -81,6 +81,7 @@ def safe_float(value: str | float | None, default: float = 0.0) -> float:
|
|||||||
TRADE_INTERVAL_SECONDS = 60
|
TRADE_INTERVAL_SECONDS = 60
|
||||||
SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds
|
SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds
|
||||||
MAX_CONNECTION_RETRIES = 3
|
MAX_CONNECTION_RETRIES = 3
|
||||||
|
_BUY_COOLDOWN_SECONDS = 600 # 10-minute cooldown after insufficient-balance rejection
|
||||||
|
|
||||||
# Daily trading mode constants (for Free tier API efficiency)
|
# Daily trading mode constants (for Free tier API efficiency)
|
||||||
DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
|
DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
|
||||||
@@ -190,8 +191,15 @@ def _determine_order_quantity(
|
|||||||
candidate: ScanCandidate | None,
|
candidate: ScanCandidate | None,
|
||||||
settings: Settings | None,
|
settings: Settings | None,
|
||||||
broker_held_qty: int = 0,
|
broker_held_qty: int = 0,
|
||||||
|
playbook_allocation_pct: float | None = None,
|
||||||
|
scenario_confidence: int = 80,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Determine order quantity using volatility-aware position sizing."""
|
"""Determine order quantity using volatility-aware position sizing.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. playbook_allocation_pct (AI-specified) scaled by scenario_confidence
|
||||||
|
2. Fallback: volatility-score-based allocation from scanner candidate
|
||||||
|
"""
|
||||||
if action == "SELL":
|
if action == "SELL":
|
||||||
return broker_held_qty
|
return broker_held_qty
|
||||||
if current_price <= 0 or total_cash <= 0:
|
if current_price <= 0 or total_cash <= 0:
|
||||||
@@ -200,6 +208,22 @@ def _determine_order_quantity(
|
|||||||
if settings is None or not settings.POSITION_SIZING_ENABLED:
|
if settings is None or not settings.POSITION_SIZING_ENABLED:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# Use AI-specified allocation_pct if available
|
||||||
|
if playbook_allocation_pct is not None:
|
||||||
|
# Confidence scaling: confidence 80 → 1.0x, confidence 95 → 1.19x
|
||||||
|
confidence_scale = scenario_confidence / 80.0
|
||||||
|
effective_pct = min(
|
||||||
|
settings.POSITION_MAX_ALLOCATION_PCT,
|
||||||
|
max(
|
||||||
|
settings.POSITION_MIN_ALLOCATION_PCT,
|
||||||
|
playbook_allocation_pct * confidence_scale,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
budget = total_cash * (effective_pct / 100.0)
|
||||||
|
quantity = int(budget // current_price)
|
||||||
|
return max(0, quantity)
|
||||||
|
|
||||||
|
# Fallback: volatility-score-based allocation
|
||||||
target_score = max(1.0, settings.POSITION_VOLATILITY_TARGET_SCORE)
|
target_score = max(1.0, settings.POSITION_VOLATILITY_TARGET_SCORE)
|
||||||
observed_score = candidate.score if candidate else target_score
|
observed_score = candidate.score if candidate else target_score
|
||||||
observed_score = max(1.0, min(100.0, observed_score))
|
observed_score = max(1.0, min(100.0, observed_score))
|
||||||
@@ -275,6 +299,7 @@ async def trading_cycle(
|
|||||||
stock_code: str,
|
stock_code: str,
|
||||||
scan_candidates: dict[str, dict[str, ScanCandidate]],
|
scan_candidates: dict[str, dict[str, ScanCandidate]],
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
|
buy_cooldown: dict[str, float] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Execute one trading cycle for a single stock."""
|
"""Execute one trading cycle for a single stock."""
|
||||||
cycle_start_time = asyncio.get_event_loop().time()
|
cycle_start_time = asyncio.get_event_loop().time()
|
||||||
@@ -457,6 +482,53 @@ async def trading_cycle(
|
|||||||
)
|
)
|
||||||
stock_playbook = playbook.get_stock_playbook(stock_code)
|
stock_playbook = playbook.get_stock_playbook(stock_code)
|
||||||
|
|
||||||
|
# 2.1. Apply market_outlook-based BUY confidence threshold
|
||||||
|
if decision.action == "BUY":
|
||||||
|
base_threshold = (settings.CONFIDENCE_THRESHOLD if settings else 80)
|
||||||
|
outlook = playbook.market_outlook
|
||||||
|
if outlook == MarketOutlook.BEARISH:
|
||||||
|
min_confidence = 90
|
||||||
|
elif outlook == MarketOutlook.BULLISH:
|
||||||
|
min_confidence = 75
|
||||||
|
else:
|
||||||
|
min_confidence = base_threshold
|
||||||
|
if match.confidence < min_confidence:
|
||||||
|
logger.info(
|
||||||
|
"BUY suppressed for %s (%s): confidence %d < %d (market_outlook=%s)",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
match.confidence,
|
||||||
|
min_confidence,
|
||||||
|
outlook.value,
|
||||||
|
)
|
||||||
|
decision = TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=match.confidence,
|
||||||
|
rationale=(
|
||||||
|
f"BUY confidence {match.confidence} < {min_confidence} "
|
||||||
|
f"(market_outlook={outlook.value})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# BUY 결정 전 기존 포지션 체크 (중복 매수 방지)
|
||||||
|
if decision.action == "BUY":
|
||||||
|
existing_position = get_open_position(db_conn, stock_code, market.code)
|
||||||
|
if existing_position:
|
||||||
|
decision = TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=decision.confidence,
|
||||||
|
rationale=(
|
||||||
|
f"Already holding {stock_code} "
|
||||||
|
f"(entry={existing_position['price']:.4f}, "
|
||||||
|
f"qty={existing_position['quantity']})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"BUY suppressed for %s (%s): already holding open position",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
)
|
||||||
|
|
||||||
if decision.action == "HOLD":
|
if decision.action == "HOLD":
|
||||||
open_position = get_open_position(db_conn, stock_code, market.code)
|
open_position = get_open_position(db_conn, stock_code, market.code)
|
||||||
if open_position:
|
if open_position:
|
||||||
@@ -568,6 +640,7 @@ async def trading_cycle(
|
|||||||
if decision.action == "SELL"
|
if decision.action == "SELL"
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
|
matched_scenario = match.matched_scenario
|
||||||
quantity = _determine_order_quantity(
|
quantity = _determine_order_quantity(
|
||||||
action=decision.action,
|
action=decision.action,
|
||||||
current_price=current_price,
|
current_price=current_price,
|
||||||
@@ -575,6 +648,8 @@ async def trading_cycle(
|
|||||||
candidate=candidate,
|
candidate=candidate,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
broker_held_qty=broker_held_qty,
|
broker_held_qty=broker_held_qty,
|
||||||
|
playbook_allocation_pct=matched_scenario.allocation_pct if matched_scenario else None,
|
||||||
|
scenario_confidence=match.confidence,
|
||||||
)
|
)
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -588,13 +663,33 @@ async def trading_cycle(
|
|||||||
return
|
return
|
||||||
order_amount = current_price * quantity
|
order_amount = current_price * quantity
|
||||||
|
|
||||||
# 4. Risk check BEFORE order
|
# 4. Check BUY cooldown (set when a prior BUY failed due to insufficient balance)
|
||||||
|
if decision.action == "BUY" and buy_cooldown is not None:
|
||||||
|
cooldown_key = f"{market.code}:{stock_code}"
|
||||||
|
cooldown_until = buy_cooldown.get(cooldown_key, 0.0)
|
||||||
|
now = asyncio.get_event_loop().time()
|
||||||
|
if now < cooldown_until:
|
||||||
|
remaining = int(cooldown_until - now)
|
||||||
|
logger.info(
|
||||||
|
"Skip BUY %s (%s): insufficient-balance cooldown active (%ds remaining)",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 5a. Risk check BEFORE order
|
||||||
|
# SELL orders do not consume cash (they receive it), so fat-finger check
|
||||||
|
# is skipped for SELLs — only circuit breaker applies.
|
||||||
try:
|
try:
|
||||||
risk.validate_order(
|
if decision.action == "SELL":
|
||||||
current_pnl_pct=pnl_pct,
|
risk.check_circuit_breaker(pnl_pct)
|
||||||
order_amount=order_amount,
|
else:
|
||||||
total_cash=total_cash,
|
risk.validate_order(
|
||||||
)
|
current_pnl_pct=pnl_pct,
|
||||||
|
order_amount=order_amount,
|
||||||
|
total_cash=total_cash,
|
||||||
|
)
|
||||||
except FatFingerRejected as exc:
|
except FatFingerRejected as exc:
|
||||||
try:
|
try:
|
||||||
await telegram.notify_fat_finger(
|
await telegram.notify_fat_finger(
|
||||||
@@ -636,12 +731,24 @@ async def trading_cycle(
|
|||||||
# Check if KIS rejected the order (rt_cd != "0")
|
# Check if KIS rejected the order (rt_cd != "0")
|
||||||
if result.get("rt_cd", "") != "0":
|
if result.get("rt_cd", "") != "0":
|
||||||
order_succeeded = False
|
order_succeeded = False
|
||||||
|
msg1 = result.get("msg1") or ""
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
|
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
|
||||||
stock_code,
|
stock_code,
|
||||||
result.get("rt_cd"),
|
result.get("rt_cd"),
|
||||||
result.get("msg1"),
|
msg1,
|
||||||
)
|
)
|
||||||
|
# Set BUY cooldown when the rejection is due to insufficient balance
|
||||||
|
if decision.action == "BUY" and buy_cooldown is not None and "주문가능금액" in msg1:
|
||||||
|
cooldown_key = f"{market.code}:{stock_code}"
|
||||||
|
buy_cooldown[cooldown_key] = (
|
||||||
|
asyncio.get_event_loop().time() + _BUY_COOLDOWN_SECONDS
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"BUY cooldown set for %s: %.0fs (insufficient balance)",
|
||||||
|
stock_code,
|
||||||
|
_BUY_COOLDOWN_SECONDS,
|
||||||
|
)
|
||||||
logger.info("Order result: %s", result.get("msg1", "OK"))
|
logger.info("Order result: %s", result.get("msg1", "OK"))
|
||||||
|
|
||||||
# 5.5. Notify trade execution (only on success)
|
# 5.5. Notify trade execution (only on success)
|
||||||
@@ -749,6 +856,9 @@ async def run_daily_session(
|
|||||||
|
|
||||||
logger.info("Starting daily trading session for %d markets", len(open_markets))
|
logger.info("Starting daily trading session for %d markets", len(open_markets))
|
||||||
|
|
||||||
|
# BUY cooldown: prevents retrying stocks rejected for insufficient balance
|
||||||
|
daily_buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp
|
||||||
|
|
||||||
# Process each open market
|
# Process each open market
|
||||||
for market in open_markets:
|
for market in open_markets:
|
||||||
# Use market-local date for playbook keying
|
# Use market-local date for playbook keying
|
||||||
@@ -1021,13 +1131,33 @@ async def run_daily_session(
|
|||||||
continue
|
continue
|
||||||
order_amount = stock_data["current_price"] * quantity
|
order_amount = stock_data["current_price"] * quantity
|
||||||
|
|
||||||
|
# Check BUY cooldown (insufficient balance)
|
||||||
|
if decision.action == "BUY":
|
||||||
|
daily_cooldown_key = f"{market.code}:{stock_code}"
|
||||||
|
daily_cooldown_until = daily_buy_cooldown.get(daily_cooldown_key, 0.0)
|
||||||
|
now = asyncio.get_event_loop().time()
|
||||||
|
if now < daily_cooldown_until:
|
||||||
|
remaining = int(daily_cooldown_until - now)
|
||||||
|
logger.info(
|
||||||
|
"Skip BUY %s (%s): insufficient-balance cooldown active (%ds remaining)",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# Risk check
|
# Risk check
|
||||||
|
# SELL orders do not consume cash (they receive it), so fat-finger
|
||||||
|
# check is skipped for SELLs — only circuit breaker applies.
|
||||||
try:
|
try:
|
||||||
risk.validate_order(
|
if decision.action == "SELL":
|
||||||
current_pnl_pct=pnl_pct,
|
risk.check_circuit_breaker(pnl_pct)
|
||||||
order_amount=order_amount,
|
else:
|
||||||
total_cash=total_cash,
|
risk.validate_order(
|
||||||
)
|
current_pnl_pct=pnl_pct,
|
||||||
|
order_amount=order_amount,
|
||||||
|
total_cash=total_cash,
|
||||||
|
)
|
||||||
except FatFingerRejected as exc:
|
except FatFingerRejected as exc:
|
||||||
try:
|
try:
|
||||||
await telegram.notify_fat_finger(
|
await telegram.notify_fat_finger(
|
||||||
@@ -1077,12 +1207,23 @@ async def run_daily_session(
|
|||||||
)
|
)
|
||||||
if result.get("rt_cd", "") != "0":
|
if result.get("rt_cd", "") != "0":
|
||||||
order_succeeded = False
|
order_succeeded = False
|
||||||
|
daily_msg1 = result.get("msg1") or ""
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
|
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
|
||||||
stock_code,
|
stock_code,
|
||||||
result.get("rt_cd"),
|
result.get("rt_cd"),
|
||||||
result.get("msg1"),
|
daily_msg1,
|
||||||
)
|
)
|
||||||
|
if decision.action == "BUY" and "주문가능금액" in daily_msg1:
|
||||||
|
daily_cooldown_key = f"{market.code}:{stock_code}"
|
||||||
|
daily_buy_cooldown[daily_cooldown_key] = (
|
||||||
|
asyncio.get_event_loop().time() + _BUY_COOLDOWN_SECONDS
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"BUY cooldown set for %s: %.0fs (insufficient balance)",
|
||||||
|
stock_code,
|
||||||
|
_BUY_COOLDOWN_SECONDS,
|
||||||
|
)
|
||||||
logger.info("Order result: %s", result.get("msg1", "OK"))
|
logger.info("Order result: %s", result.get("msg1", "OK"))
|
||||||
|
|
||||||
# Notify trade execution (only on success)
|
# Notify trade execution (only on success)
|
||||||
@@ -1246,10 +1387,18 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
|
|||||||
if not settings.DASHBOARD_ENABLED:
|
if not settings.DASHBOARD_ENABLED:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Validate dependencies before spawning the thread so startup failures are
|
||||||
|
# reported synchronously (avoids the misleading "started" → "failed" log pair).
|
||||||
|
try:
|
||||||
|
import uvicorn # noqa: F401
|
||||||
|
from src.dashboard import create_dashboard_app # noqa: F401
|
||||||
|
except ImportError as exc:
|
||||||
|
logger.warning("Dashboard server unavailable (missing dependency): %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
def _serve() -> None:
|
def _serve() -> None:
|
||||||
try:
|
try:
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from src.dashboard import create_dashboard_app
|
from src.dashboard import create_dashboard_app
|
||||||
|
|
||||||
app = create_dashboard_app(settings.DB_PATH)
|
app = create_dashboard_app(settings.DB_PATH)
|
||||||
@@ -1260,7 +1409,7 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
|
|||||||
log_level="info",
|
log_level="info",
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Dashboard server failed to start: %s", exc)
|
logger.warning("Dashboard server stopped unexpectedly: %s", exc)
|
||||||
|
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=_serve,
|
target=_serve,
|
||||||
@@ -1701,6 +1850,9 @@ async def run(settings: Settings) -> None:
|
|||||||
# Active stocks per market (dynamically discovered by scanner)
|
# Active stocks per market (dynamically discovered by scanner)
|
||||||
active_stocks: dict[str, list[str]] = {} # market_code -> [stock_codes]
|
active_stocks: dict[str, list[str]] = {} # market_code -> [stock_codes]
|
||||||
|
|
||||||
|
# BUY cooldown: prevents retrying a stock rejected for insufficient balance
|
||||||
|
buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp
|
||||||
|
|
||||||
# Initialize latency control system
|
# Initialize latency control system
|
||||||
criticality_assessor = CriticalityAssessor(
|
criticality_assessor = CriticalityAssessor(
|
||||||
critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0%
|
critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0%
|
||||||
@@ -2043,6 +2195,7 @@ async def run(settings: Settings) -> None:
|
|||||||
stock_code,
|
stock_code,
|
||||||
scan_candidates,
|
scan_candidates,
|
||||||
settings,
|
settings,
|
||||||
|
buy_cooldown,
|
||||||
)
|
)
|
||||||
break # Success — exit retry loop
|
break # Success — exit retry loop
|
||||||
except CircuitBreakerTripped as exc:
|
except CircuitBreakerTripped as exc:
|
||||||
|
|||||||
@@ -604,9 +604,19 @@ class TelegramCommandHandler:
|
|||||||
async with session.post(url, json=payload) as resp:
|
async with session.post(url, json=payload) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
error_text = await resp.text()
|
error_text = await resp.text()
|
||||||
logger.error(
|
if resp.status == 409:
|
||||||
"getUpdates API error (status=%d): %s", resp.status, error_text
|
# Another bot instance is already polling — stop this poller entirely.
|
||||||
)
|
# Retrying would keep conflicting with the other instance.
|
||||||
|
self._running = False
|
||||||
|
logger.warning(
|
||||||
|
"Telegram conflict (409): another instance is already polling. "
|
||||||
|
"Disabling Telegram commands for this process. "
|
||||||
|
"Ensure only one instance of The Ouroboros is running at a time.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"getUpdates API error (status=%d): %s", resp.status, error_text
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ class StockCondition(BaseModel):
|
|||||||
|
|
||||||
The ScenarioEngine evaluates all non-None fields as AND conditions.
|
The ScenarioEngine evaluates all non-None fields as AND conditions.
|
||||||
A condition matches only if ALL specified fields are satisfied.
|
A condition matches only if ALL specified fields are satisfied.
|
||||||
|
|
||||||
|
Technical indicator fields:
|
||||||
|
rsi_below / rsi_above — RSI threshold
|
||||||
|
volume_ratio_above / volume_ratio_below — volume vs previous day
|
||||||
|
price_above / price_below — absolute price level
|
||||||
|
price_change_pct_above / price_change_pct_below — intraday % change
|
||||||
|
|
||||||
|
Position-aware fields (require market_data enrichment from open position):
|
||||||
|
unrealized_pnl_pct_above — matches if unrealized P&L > threshold (e.g. 3.0 → +3%)
|
||||||
|
unrealized_pnl_pct_below — matches if unrealized P&L < threshold (e.g. -2.0 → -2%)
|
||||||
|
holding_days_above — matches if position held for more than N days
|
||||||
|
holding_days_below — matches if position held for fewer than N days
|
||||||
"""
|
"""
|
||||||
|
|
||||||
rsi_below: float | None = None
|
rsi_below: float | None = None
|
||||||
@@ -56,6 +68,10 @@ class StockCondition(BaseModel):
|
|||||||
price_below: float | None = None
|
price_below: float | None = None
|
||||||
price_change_pct_above: float | None = None
|
price_change_pct_above: float | None = None
|
||||||
price_change_pct_below: float | None = None
|
price_change_pct_below: float | None = None
|
||||||
|
unrealized_pnl_pct_above: float | None = None
|
||||||
|
unrealized_pnl_pct_below: float | None = None
|
||||||
|
holding_days_above: int | None = None
|
||||||
|
holding_days_below: int | None = None
|
||||||
|
|
||||||
def has_any_condition(self) -> bool:
|
def has_any_condition(self) -> bool:
|
||||||
"""Check if at least one condition field is set."""
|
"""Check if at least one condition field is set."""
|
||||||
@@ -70,6 +86,10 @@ class StockCondition(BaseModel):
|
|||||||
self.price_below,
|
self.price_below,
|
||||||
self.price_change_pct_above,
|
self.price_change_pct_above,
|
||||||
self.price_change_pct_below,
|
self.price_change_pct_below,
|
||||||
|
self.unrealized_pnl_pct_above,
|
||||||
|
self.unrealized_pnl_pct_below,
|
||||||
|
self.holding_days_above,
|
||||||
|
self.holding_days_below,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class PreMarketPlanner:
|
|||||||
market: str,
|
market: str,
|
||||||
candidates: list[ScanCandidate],
|
candidates: list[ScanCandidate],
|
||||||
today: date | None = None,
|
today: date | None = None,
|
||||||
|
current_holdings: list[dict] | None = None,
|
||||||
) -> DayPlaybook:
|
) -> DayPlaybook:
|
||||||
"""Generate a DayPlaybook for a market using Gemini.
|
"""Generate a DayPlaybook for a market using Gemini.
|
||||||
|
|
||||||
@@ -82,6 +83,10 @@ class PreMarketPlanner:
|
|||||||
market: Market code ("KR" or "US")
|
market: Market code ("KR" or "US")
|
||||||
candidates: Stock candidates from SmartVolatilityScanner
|
candidates: Stock candidates from SmartVolatilityScanner
|
||||||
today: Override date (defaults to date.today()). Use market-local date.
|
today: Override date (defaults to date.today()). Use market-local date.
|
||||||
|
current_holdings: Currently held positions with entry_price and unrealized_pnl_pct.
|
||||||
|
Each dict: {"stock_code": str, "name": str, "qty": int,
|
||||||
|
"entry_price": float, "unrealized_pnl_pct": float,
|
||||||
|
"holding_days": int}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DayPlaybook with scenarios. Empty/defensive if no candidates or failure.
|
DayPlaybook with scenarios. Empty/defensive if no candidates or failure.
|
||||||
@@ -106,6 +111,7 @@ class PreMarketPlanner:
|
|||||||
context_data,
|
context_data,
|
||||||
self_market_scorecard,
|
self_market_scorecard,
|
||||||
cross_market,
|
cross_market,
|
||||||
|
current_holdings=current_holdings,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Call Gemini
|
# 3. Call Gemini
|
||||||
@@ -118,7 +124,8 @@ class PreMarketPlanner:
|
|||||||
|
|
||||||
# 4. Parse response
|
# 4. Parse response
|
||||||
playbook = self._parse_response(
|
playbook = self._parse_response(
|
||||||
decision.rationale, today, market, candidates, cross_market
|
decision.rationale, today, market, candidates, cross_market,
|
||||||
|
current_holdings=current_holdings,
|
||||||
)
|
)
|
||||||
playbook_with_tokens = playbook.model_copy(
|
playbook_with_tokens = playbook.model_copy(
|
||||||
update={"token_count": decision.token_count}
|
update={"token_count": decision.token_count}
|
||||||
@@ -230,6 +237,7 @@ class PreMarketPlanner:
|
|||||||
context_data: dict[str, Any],
|
context_data: dict[str, Any],
|
||||||
self_market_scorecard: dict[str, Any] | None,
|
self_market_scorecard: dict[str, Any] | None,
|
||||||
cross_market: CrossMarketContext | None,
|
cross_market: CrossMarketContext | None,
|
||||||
|
current_holdings: list[dict] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build a structured prompt for Gemini to generate scenario JSON."""
|
"""Build a structured prompt for Gemini to generate scenario JSON."""
|
||||||
max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK
|
max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK
|
||||||
@@ -241,6 +249,26 @@ class PreMarketPlanner:
|
|||||||
for c in candidates
|
for c in candidates
|
||||||
)
|
)
|
||||||
|
|
||||||
|
holdings_text = ""
|
||||||
|
if current_holdings:
|
||||||
|
lines = []
|
||||||
|
for h in current_holdings:
|
||||||
|
code = h.get("stock_code", "")
|
||||||
|
name = h.get("name", "")
|
||||||
|
qty = h.get("qty", 0)
|
||||||
|
entry_price = h.get("entry_price", 0.0)
|
||||||
|
pnl_pct = h.get("unrealized_pnl_pct", 0.0)
|
||||||
|
holding_days = h.get("holding_days", 0)
|
||||||
|
lines.append(
|
||||||
|
f" - {code} ({name}): {qty}주 @ {entry_price:,.0f}, "
|
||||||
|
f"미실현손익 {pnl_pct:+.2f}%, 보유 {holding_days}일"
|
||||||
|
)
|
||||||
|
holdings_text = (
|
||||||
|
"\n## Current Holdings (보유 중 — SELL/HOLD 전략 고려 필요)\n"
|
||||||
|
+ "\n".join(lines)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
cross_market_text = ""
|
cross_market_text = ""
|
||||||
if cross_market:
|
if cross_market:
|
||||||
cross_market_text = (
|
cross_market_text = (
|
||||||
@@ -273,10 +301,20 @@ class PreMarketPlanner:
|
|||||||
for key, value in list(layer_data.items())[:5]:
|
for key, value in list(layer_data.items())[:5]:
|
||||||
context_text += f" - {key}: {value}\n"
|
context_text += f" - {key}: {value}\n"
|
||||||
|
|
||||||
|
holdings_instruction = ""
|
||||||
|
if current_holdings:
|
||||||
|
holding_codes = [h.get("stock_code", "") for h in current_holdings]
|
||||||
|
holdings_instruction = (
|
||||||
|
f"- Also include SELL/HOLD scenarios for held stocks: "
|
||||||
|
f"{', '.join(holding_codes)} "
|
||||||
|
f"(even if not in candidates list)\n"
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"You are a pre-market trading strategist for the {market} market.\n"
|
f"You are a pre-market trading strategist for the {market} market.\n"
|
||||||
f"Generate structured trading scenarios for today.\n\n"
|
f"Generate structured trading scenarios for today.\n\n"
|
||||||
f"## Candidates (from volatility scanner)\n{candidates_text}\n"
|
f"## Candidates (from volatility scanner)\n{candidates_text}\n"
|
||||||
|
f"{holdings_text}"
|
||||||
f"{self_market_text}"
|
f"{self_market_text}"
|
||||||
f"{cross_market_text}"
|
f"{cross_market_text}"
|
||||||
f"{context_text}\n"
|
f"{context_text}\n"
|
||||||
@@ -294,7 +332,8 @@ class PreMarketPlanner:
|
|||||||
f' "stock_code": "...",\n'
|
f' "stock_code": "...",\n'
|
||||||
f' "scenarios": [\n'
|
f' "scenarios": [\n'
|
||||||
f' {{\n'
|
f' {{\n'
|
||||||
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0}},\n'
|
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0,'
|
||||||
|
f' "unrealized_pnl_pct_above": 3.0, "holding_days_above": 5}},\n'
|
||||||
f' "action": "BUY|SELL|HOLD",\n'
|
f' "action": "BUY|SELL|HOLD",\n'
|
||||||
f' "confidence": 85,\n'
|
f' "confidence": 85,\n'
|
||||||
f' "allocation_pct": 10.0,\n'
|
f' "allocation_pct": 10.0,\n'
|
||||||
@@ -308,7 +347,8 @@ class PreMarketPlanner:
|
|||||||
f'}}\n\n'
|
f'}}\n\n'
|
||||||
f"Rules:\n"
|
f"Rules:\n"
|
||||||
f"- Max {max_scenarios} scenarios per stock\n"
|
f"- Max {max_scenarios} scenarios per stock\n"
|
||||||
f"- Only use stocks from the candidates list\n"
|
f"- Candidates list is the primary source for BUY candidates\n"
|
||||||
|
f"{holdings_instruction}"
|
||||||
f"- Confidence 0-100 (80+ for actionable trades)\n"
|
f"- Confidence 0-100 (80+ for actionable trades)\n"
|
||||||
f"- stop_loss_pct must be <= 0, take_profit_pct must be >= 0\n"
|
f"- stop_loss_pct must be <= 0, take_profit_pct must be >= 0\n"
|
||||||
f"- Return ONLY the JSON, no markdown fences or explanation\n"
|
f"- Return ONLY the JSON, no markdown fences or explanation\n"
|
||||||
@@ -321,12 +361,19 @@ class PreMarketPlanner:
|
|||||||
market: str,
|
market: str,
|
||||||
candidates: list[ScanCandidate],
|
candidates: list[ScanCandidate],
|
||||||
cross_market: CrossMarketContext | None,
|
cross_market: CrossMarketContext | None,
|
||||||
|
current_holdings: list[dict] | None = None,
|
||||||
) -> DayPlaybook:
|
) -> DayPlaybook:
|
||||||
"""Parse Gemini's JSON response into a validated DayPlaybook."""
|
"""Parse Gemini's JSON response into a validated DayPlaybook."""
|
||||||
cleaned = self._extract_json(response_text)
|
cleaned = self._extract_json(response_text)
|
||||||
data = json.loads(cleaned)
|
data = json.loads(cleaned)
|
||||||
|
|
||||||
valid_codes = {c.stock_code for c in candidates}
|
valid_codes = {c.stock_code for c in candidates}
|
||||||
|
# Holdings are also valid — AI may generate SELL/HOLD scenarios for them
|
||||||
|
if current_holdings:
|
||||||
|
for h in current_holdings:
|
||||||
|
code = h.get("stock_code", "")
|
||||||
|
if code:
|
||||||
|
valid_codes.add(code)
|
||||||
|
|
||||||
# Parse market outlook
|
# Parse market outlook
|
||||||
outlook_str = data.get("market_outlook", "neutral")
|
outlook_str = data.get("market_outlook", "neutral")
|
||||||
@@ -390,6 +437,10 @@ class PreMarketPlanner:
|
|||||||
price_below=cond_data.get("price_below"),
|
price_below=cond_data.get("price_below"),
|
||||||
price_change_pct_above=cond_data.get("price_change_pct_above"),
|
price_change_pct_above=cond_data.get("price_change_pct_above"),
|
||||||
price_change_pct_below=cond_data.get("price_change_pct_below"),
|
price_change_pct_below=cond_data.get("price_change_pct_below"),
|
||||||
|
unrealized_pnl_pct_above=cond_data.get("unrealized_pnl_pct_above"),
|
||||||
|
unrealized_pnl_pct_below=cond_data.get("unrealized_pnl_pct_below"),
|
||||||
|
holding_days_above=cond_data.get("holding_days_above"),
|
||||||
|
holding_days_below=cond_data.get("holding_days_below"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not condition.has_any_condition():
|
if not condition.has_any_condition():
|
||||||
|
|||||||
@@ -206,6 +206,37 @@ class ScenarioEngine:
|
|||||||
if condition.price_change_pct_below is not None:
|
if condition.price_change_pct_below is not None:
|
||||||
checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below)
|
checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below)
|
||||||
|
|
||||||
|
# Position-aware conditions
|
||||||
|
unrealized_pnl_pct = self._safe_float(market_data.get("unrealized_pnl_pct"))
|
||||||
|
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
|
||||||
|
if "unrealized_pnl_pct" not in market_data:
|
||||||
|
self._warn_missing_key("unrealized_pnl_pct")
|
||||||
|
if condition.unrealized_pnl_pct_above is not None:
|
||||||
|
checks.append(
|
||||||
|
unrealized_pnl_pct is not None
|
||||||
|
and unrealized_pnl_pct > condition.unrealized_pnl_pct_above
|
||||||
|
)
|
||||||
|
if condition.unrealized_pnl_pct_below is not None:
|
||||||
|
checks.append(
|
||||||
|
unrealized_pnl_pct is not None
|
||||||
|
and unrealized_pnl_pct < condition.unrealized_pnl_pct_below
|
||||||
|
)
|
||||||
|
|
||||||
|
holding_days = self._safe_float(market_data.get("holding_days"))
|
||||||
|
if condition.holding_days_above is not None or condition.holding_days_below is not None:
|
||||||
|
if "holding_days" not in market_data:
|
||||||
|
self._warn_missing_key("holding_days")
|
||||||
|
if condition.holding_days_above is not None:
|
||||||
|
checks.append(
|
||||||
|
holding_days is not None
|
||||||
|
and holding_days > condition.holding_days_above
|
||||||
|
)
|
||||||
|
if condition.holding_days_below is not None:
|
||||||
|
checks.append(
|
||||||
|
holding_days is not None
|
||||||
|
and holding_days < condition.holding_days_below
|
||||||
|
)
|
||||||
|
|
||||||
return len(checks) > 0 and all(checks)
|
return len(checks) > 0 and all(checks)
|
||||||
|
|
||||||
def _evaluate_global_condition(
|
def _evaluate_global_condition(
|
||||||
@@ -266,5 +297,9 @@ class ScenarioEngine:
|
|||||||
details["current_price"] = self._safe_float(market_data.get("current_price"))
|
details["current_price"] = self._safe_float(market_data.get("current_price"))
|
||||||
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
|
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
|
||||||
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
|
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
|
||||||
|
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
|
||||||
|
details["unrealized_pnl_pct"] = self._safe_float(market_data.get("unrealized_pnl_pct"))
|
||||||
|
if condition.holding_days_above is not None or condition.holding_days_below is not None:
|
||||||
|
details["holding_days"] = self._safe_float(market_data.get("holding_days"))
|
||||||
|
|
||||||
return details
|
return details
|
||||||
|
|||||||
@@ -316,3 +316,38 @@ def test_pnl_history_market_filter(tmp_path: Path) -> None:
|
|||||||
# KR has 1 trade with pnl=2.0
|
# KR has 1 trade with pnl=2.0
|
||||||
assert len(body["labels"]) >= 1
|
assert len(body["labels"]) >= 1
|
||||||
assert body["pnl"][0] == 2.0
|
assert body["pnl"][0] == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_returns_open_buy(tmp_path: Path) -> None:
|
||||||
|
"""BUY가 마지막 거래인 종목은 포지션으로 반환되어야 한다."""
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_positions = _endpoint(app, "/api/positions")
|
||||||
|
body = get_positions()
|
||||||
|
# seed_db: 005930은 BUY (오픈), AAPL은 SELL (마지막)
|
||||||
|
assert body["count"] == 1
|
||||||
|
pos = body["positions"][0]
|
||||||
|
assert pos["stock_code"] == "005930"
|
||||||
|
assert pos["market"] == "KR"
|
||||||
|
assert pos["quantity"] == 1
|
||||||
|
assert pos["entry_price"] == 70000
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_excludes_closed_sell(tmp_path: Path) -> None:
|
||||||
|
"""마지막 거래가 SELL인 종목은 포지션에 나타나지 않아야 한다."""
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_positions = _endpoint(app, "/api/positions")
|
||||||
|
body = get_positions()
|
||||||
|
codes = [p["stock_code"] for p in body["positions"]]
|
||||||
|
assert "AAPL" not in codes
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_empty_when_no_trades(tmp_path: Path) -> None:
|
||||||
|
"""거래 내역이 없으면 빈 포지션 목록을 반환해야 한다."""
|
||||||
|
db_path = tmp_path / "empty.db"
|
||||||
|
conn = init_db(str(db_path))
|
||||||
|
conn.close()
|
||||||
|
app = create_dashboard_app(str(db_path))
|
||||||
|
get_positions = _endpoint(app, "/api/positions")
|
||||||
|
body = get_positions()
|
||||||
|
assert body["count"] == 0
|
||||||
|
assert body["positions"] == []
|
||||||
|
|||||||
@@ -205,6 +205,84 @@ class TestDetermineOrderQuantity:
|
|||||||
)
|
)
|
||||||
assert result == 2
|
assert result == 2
|
||||||
|
|
||||||
|
def test_determine_order_quantity_uses_playbook_allocation_pct(self) -> None:
|
||||||
|
"""playbook_allocation_pct should take priority over volatility-based sizing."""
|
||||||
|
settings = MagicMock(spec=Settings)
|
||||||
|
settings.POSITION_SIZING_ENABLED = True
|
||||||
|
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
|
||||||
|
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
|
||||||
|
# playbook says 20%, confidence 80 → scale=1.0 → 20%
|
||||||
|
# 1,000,000 * 20% = 200,000 // 50,000 price = 4 shares
|
||||||
|
result = _determine_order_quantity(
|
||||||
|
action="BUY",
|
||||||
|
current_price=50000.0,
|
||||||
|
total_cash=1000000.0,
|
||||||
|
candidate=None,
|
||||||
|
settings=settings,
|
||||||
|
playbook_allocation_pct=20.0,
|
||||||
|
scenario_confidence=80,
|
||||||
|
)
|
||||||
|
assert result == 4
|
||||||
|
|
||||||
|
def test_determine_order_quantity_confidence_scales_allocation(self) -> None:
|
||||||
|
"""Higher confidence should produce a larger allocation (up to max)."""
|
||||||
|
settings = MagicMock(spec=Settings)
|
||||||
|
settings.POSITION_SIZING_ENABLED = True
|
||||||
|
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
|
||||||
|
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
|
||||||
|
# confidence 96 → scale=1.2 → 10% * 1.2 = 12%
|
||||||
|
# 1,000,000 * 12% = 120,000 // 50,000 price = 2 shares
|
||||||
|
result = _determine_order_quantity(
|
||||||
|
action="BUY",
|
||||||
|
current_price=50000.0,
|
||||||
|
total_cash=1000000.0,
|
||||||
|
candidate=None,
|
||||||
|
settings=settings,
|
||||||
|
playbook_allocation_pct=10.0,
|
||||||
|
scenario_confidence=96,
|
||||||
|
)
|
||||||
|
# scale = 96/80 = 1.2 → effective_pct = 12.0
|
||||||
|
# budget = 1_000_000 * 0.12 = 120_000 → qty = 120_000 // 50_000 = 2
|
||||||
|
assert result == 2
|
||||||
|
|
||||||
|
def test_determine_order_quantity_confidence_clamped_to_max(self) -> None:
|
||||||
|
"""Confidence scaling should not exceed POSITION_MAX_ALLOCATION_PCT."""
|
||||||
|
settings = MagicMock(spec=Settings)
|
||||||
|
settings.POSITION_SIZING_ENABLED = True
|
||||||
|
settings.POSITION_MAX_ALLOCATION_PCT = 15.0
|
||||||
|
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
|
||||||
|
# playbook 20% * scale 1.5 = 30% → clamped to 15%
|
||||||
|
# 1,000,000 * 15% = 150,000 // 50,000 price = 3 shares
|
||||||
|
result = _determine_order_quantity(
|
||||||
|
action="BUY",
|
||||||
|
current_price=50000.0,
|
||||||
|
total_cash=1000000.0,
|
||||||
|
candidate=None,
|
||||||
|
settings=settings,
|
||||||
|
playbook_allocation_pct=20.0,
|
||||||
|
scenario_confidence=120, # extreme → scale = 1.5
|
||||||
|
)
|
||||||
|
assert result == 3
|
||||||
|
|
||||||
|
def test_determine_order_quantity_fallback_when_no_playbook(self) -> None:
|
||||||
|
"""Without playbook_allocation_pct, falls back to volatility-based sizing."""
|
||||||
|
settings = MagicMock(spec=Settings)
|
||||||
|
settings.POSITION_SIZING_ENABLED = True
|
||||||
|
settings.POSITION_VOLATILITY_TARGET_SCORE = 50.0
|
||||||
|
settings.POSITION_BASE_ALLOCATION_PCT = 10.0
|
||||||
|
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
|
||||||
|
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
|
||||||
|
# Same as test_buy_with_position_sizing_calculates_correctly (no playbook)
|
||||||
|
result = _determine_order_quantity(
|
||||||
|
action="BUY",
|
||||||
|
current_price=50000.0,
|
||||||
|
total_cash=1000000.0,
|
||||||
|
candidate=None,
|
||||||
|
settings=settings,
|
||||||
|
playbook_allocation_pct=None, # explicit None → fallback
|
||||||
|
)
|
||||||
|
assert result == 2
|
||||||
|
|
||||||
|
|
||||||
class TestSafeFloat:
|
class TestSafeFloat:
|
||||||
"""Test safe_float() helper function."""
|
"""Test safe_float() helper function."""
|
||||||
@@ -553,6 +631,119 @@ class TestTradingCycleTelegramIntegration:
|
|||||||
# Verify no trade notification sent
|
# Verify no trade notification sent
|
||||||
mock_telegram.notify_trade_execution.assert_not_called()
|
mock_telegram.notify_trade_execution.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sell_skips_fat_finger_check(
|
||||||
|
self,
|
||||||
|
mock_broker: MagicMock,
|
||||||
|
mock_overseas_broker: MagicMock,
|
||||||
|
mock_scenario_engine: MagicMock,
|
||||||
|
mock_playbook: DayPlaybook,
|
||||||
|
mock_risk: MagicMock,
|
||||||
|
mock_db: MagicMock,
|
||||||
|
mock_decision_logger: MagicMock,
|
||||||
|
mock_context_store: MagicMock,
|
||||||
|
mock_criticality_assessor: MagicMock,
|
||||||
|
mock_telegram: MagicMock,
|
||||||
|
mock_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""SELL orders must not be blocked by fat-finger check.
|
||||||
|
|
||||||
|
Even if position value > 30% of cash (e.g. stop-loss on a large holding
|
||||||
|
with low remaining cash), the SELL should proceed — only circuit breaker
|
||||||
|
applies to SELLs.
|
||||||
|
"""
|
||||||
|
# SELL decision with held qty=100 shares @ 50,000 = 5,000,000
|
||||||
|
# cash = 5,000,000 → ratio = 100% which would normally trigger fat finger
|
||||||
|
mock_scenario_engine.evaluate = MagicMock(return_value=_make_sell_match())
|
||||||
|
mock_broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output1": [{"pdno": "005930", "ord_psbl_qty": "100"}],
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"tot_evlu_amt": "10000000",
|
||||||
|
"dnca_tot_amt": "5000000",
|
||||||
|
"pchs_amt_smtl_amt": "5000000",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
scenario_engine=mock_scenario_engine,
|
||||||
|
playbook=mock_playbook,
|
||||||
|
risk=mock_risk,
|
||||||
|
db_conn=mock_db,
|
||||||
|
decision_logger=mock_decision_logger,
|
||||||
|
context_store=mock_context_store,
|
||||||
|
criticality_assessor=mock_criticality_assessor,
|
||||||
|
telegram=mock_telegram,
|
||||||
|
market=mock_market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# validate_order (which includes fat finger) must NOT be called for SELL
|
||||||
|
mock_risk.validate_order.assert_not_called()
|
||||||
|
# check_circuit_breaker MUST be called for SELL
|
||||||
|
mock_risk.check_circuit_breaker.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sell_circuit_breaker_still_applies(
|
||||||
|
self,
|
||||||
|
mock_broker: MagicMock,
|
||||||
|
mock_overseas_broker: MagicMock,
|
||||||
|
mock_scenario_engine: MagicMock,
|
||||||
|
mock_playbook: DayPlaybook,
|
||||||
|
mock_risk: MagicMock,
|
||||||
|
mock_db: MagicMock,
|
||||||
|
mock_decision_logger: MagicMock,
|
||||||
|
mock_context_store: MagicMock,
|
||||||
|
mock_criticality_assessor: MagicMock,
|
||||||
|
mock_telegram: MagicMock,
|
||||||
|
mock_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""SELL orders must still respect the circuit breaker."""
|
||||||
|
mock_scenario_engine.evaluate = MagicMock(return_value=_make_sell_match())
|
||||||
|
mock_broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output1": [{"pdno": "005930", "ord_psbl_qty": "100"}],
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"tot_evlu_amt": "10000000",
|
||||||
|
"dnca_tot_amt": "5000000",
|
||||||
|
"pchs_amt_smtl_amt": "5000000",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_risk.check_circuit_breaker.side_effect = CircuitBreakerTripped(
|
||||||
|
pnl_pct=-4.0, threshold=-3.0
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
with pytest.raises(CircuitBreakerTripped):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
scenario_engine=mock_scenario_engine,
|
||||||
|
playbook=mock_playbook,
|
||||||
|
risk=mock_risk,
|
||||||
|
db_conn=mock_db,
|
||||||
|
decision_logger=mock_decision_logger,
|
||||||
|
context_store=mock_context_store,
|
||||||
|
criticality_assessor=mock_criticality_assessor,
|
||||||
|
telegram=mock_telegram,
|
||||||
|
market=mock_market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_risk.check_circuit_breaker.assert_called_once()
|
||||||
|
mock_risk.validate_order.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestRunFunctionTelegramIntegration:
|
class TestRunFunctionTelegramIntegration:
|
||||||
"""Test telegram notifications in run function."""
|
"""Test telegram notifications in run function."""
|
||||||
@@ -2114,3 +2305,699 @@ def test_start_dashboard_server_enabled_starts_thread() -> None:
|
|||||||
assert thread == mock_thread
|
assert thread == mock_thread
|
||||||
mock_thread_cls.assert_called_once()
|
mock_thread_cls.assert_called_once()
|
||||||
mock_thread.start.assert_called_once()
|
mock_thread.start.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_dashboard_server_returns_none_when_uvicorn_missing() -> None:
|
||||||
|
"""Returns None (no thread) and logs a warning when uvicorn is not installed."""
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="test_key",
|
||||||
|
KIS_APP_SECRET="test_secret",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="test_gemini_key",
|
||||||
|
DASHBOARD_ENABLED=True,
|
||||||
|
)
|
||||||
|
import builtins
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def mock_import(name: str, *args: object, **kwargs: object) -> object:
|
||||||
|
if name == "uvicorn":
|
||||||
|
raise ImportError("No module named 'uvicorn'")
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch("builtins.__import__", side_effect=mock_import):
|
||||||
|
thread = _start_dashboard_server(settings)
|
||||||
|
|
||||||
|
assert thread is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BUY cooldown tests (#179)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuyCooldown:
|
||||||
|
"""Tests for BUY cooldown after insufficient-balance rejection."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_broker(self) -> MagicMock:
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_current_price = AsyncMock(return_value=(100.0, 1.0, 0.0))
|
||||||
|
broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output2": [{"tot_evlu_amt": "1000000", "dnca_tot_amt": "500000",
|
||||||
|
"pchs_amt_smtl_amt": "500000"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
return broker
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_market(self) -> MagicMock:
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "Korea"
|
||||||
|
market.code = "KR"
|
||||||
|
market.exchange_code = "KRX"
|
||||||
|
market.is_domestic = True
|
||||||
|
return market
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_overseas_market(self) -> MagicMock:
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "NASDAQ"
|
||||||
|
market.code = "US_NASDAQ"
|
||||||
|
market.exchange_code = "NAS"
|
||||||
|
market.is_domestic = False
|
||||||
|
return market
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_overseas_broker(self) -> MagicMock:
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_overseas_price = AsyncMock(
|
||||||
|
return_value={"output": {"last": "1.0", "rate": "0.0",
|
||||||
|
"high": "1.05", "low": "0.95", "tvol": "1000000"}}
|
||||||
|
)
|
||||||
|
broker.get_overseas_balance = AsyncMock(return_value={
|
||||||
|
"output1": [],
|
||||||
|
"output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000",
|
||||||
|
"frcr_buy_amt_smtl": "0"}],
|
||||||
|
})
|
||||||
|
broker.send_overseas_order = AsyncMock(
|
||||||
|
return_value={"rt_cd": "1", "msg1": "모의투자 주문가능금액이 부족합니다."}
|
||||||
|
)
|
||||||
|
return broker
|
||||||
|
|
||||||
|
def _make_buy_match_overseas(self, stock_code: str = "MLECW") -> ScenarioMatch:
|
||||||
|
return ScenarioMatch(
|
||||||
|
stock_code=stock_code,
|
||||||
|
matched_scenario=None,
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=85,
|
||||||
|
rationale="Test buy",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cooldown_set_on_insufficient_balance(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock,
|
||||||
|
mock_overseas_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""BUY cooldown entry is created after 주문가능금액 rejection."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW"))
|
||||||
|
buy_cooldown: dict[str, float] = {}
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=_make_playbook("US_NASDAQ"),
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=MagicMock(),
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=MagicMock(
|
||||||
|
notify_trade_execution=AsyncMock(),
|
||||||
|
notify_fat_finger=AsyncMock(),
|
||||||
|
notify_circuit_breaker=AsyncMock(),
|
||||||
|
notify_scenario_matched=AsyncMock(),
|
||||||
|
),
|
||||||
|
market=mock_overseas_market,
|
||||||
|
stock_code="MLECW",
|
||||||
|
scan_candidates={},
|
||||||
|
buy_cooldown=buy_cooldown,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "US_NASDAQ:MLECW" in buy_cooldown
|
||||||
|
assert buy_cooldown["US_NASDAQ:MLECW"] > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cooldown_skips_buy(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock,
|
||||||
|
mock_overseas_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""BUY is skipped when cooldown is active for the stock."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW"))
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
# Set an active cooldown (expires far in the future)
|
||||||
|
buy_cooldown: dict[str, float] = {
|
||||||
|
"US_NASDAQ:MLECW": asyncio.get_event_loop().time() + 600
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=_make_playbook("US_NASDAQ"),
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=MagicMock(),
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=MagicMock(
|
||||||
|
notify_trade_execution=AsyncMock(),
|
||||||
|
notify_fat_finger=AsyncMock(),
|
||||||
|
notify_circuit_breaker=AsyncMock(),
|
||||||
|
notify_scenario_matched=AsyncMock(),
|
||||||
|
),
|
||||||
|
market=mock_overseas_market,
|
||||||
|
stock_code="MLECW",
|
||||||
|
scan_candidates={},
|
||||||
|
buy_cooldown=buy_cooldown,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order should NOT have been sent
|
||||||
|
mock_overseas_broker.send_overseas_order.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cooldown_not_set_on_other_errors(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Cooldown is NOT set for non-balance-related rejections."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW"))
|
||||||
|
# Different rejection reason
|
||||||
|
overseas_broker = MagicMock()
|
||||||
|
overseas_broker.get_overseas_price = AsyncMock(
|
||||||
|
return_value={"output": {"last": "1.0", "rate": "0.0",
|
||||||
|
"high": "1.05", "low": "0.95", "tvol": "1000000"}}
|
||||||
|
)
|
||||||
|
overseas_broker.get_overseas_balance = AsyncMock(return_value={
|
||||||
|
"output1": [],
|
||||||
|
"output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000",
|
||||||
|
"frcr_buy_amt_smtl": "0"}],
|
||||||
|
})
|
||||||
|
overseas_broker.send_overseas_order = AsyncMock(
|
||||||
|
return_value={"rt_cd": "1", "msg1": "기타 오류 메시지"}
|
||||||
|
)
|
||||||
|
buy_cooldown: dict[str, float] = {}
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=overseas_broker,
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=_make_playbook("US_NASDAQ"),
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=MagicMock(),
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=MagicMock(
|
||||||
|
notify_trade_execution=AsyncMock(),
|
||||||
|
notify_fat_finger=AsyncMock(),
|
||||||
|
notify_circuit_breaker=AsyncMock(),
|
||||||
|
notify_scenario_matched=AsyncMock(),
|
||||||
|
),
|
||||||
|
market=mock_overseas_market,
|
||||||
|
stock_code="MLECW",
|
||||||
|
scan_candidates={},
|
||||||
|
buy_cooldown=buy_cooldown,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cooldown should NOT be set for non-balance errors
|
||||||
|
assert "US_NASDAQ:MLECW" not in buy_cooldown
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_cooldown_param_still_works(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock,
|
||||||
|
mock_overseas_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""trading_cycle works normally when buy_cooldown is None (default)."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW"))
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=_make_playbook("US_NASDAQ"),
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=MagicMock(),
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=MagicMock(
|
||||||
|
notify_trade_execution=AsyncMock(),
|
||||||
|
notify_fat_finger=AsyncMock(),
|
||||||
|
notify_circuit_breaker=AsyncMock(),
|
||||||
|
notify_scenario_matched=AsyncMock(),
|
||||||
|
),
|
||||||
|
market=mock_overseas_market,
|
||||||
|
stock_code="MLECW",
|
||||||
|
scan_candidates={},
|
||||||
|
# buy_cooldown not passed → defaults to None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should attempt the order (and fail), but not crash
|
||||||
|
mock_overseas_broker.send_overseas_order.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# market_outlook BUY confidence threshold tests (#173)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarketOutlookConfidenceThreshold:
|
||||||
|
"""Tests for market_outlook-based BUY confidence suppression in trading_cycle."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_broker(self) -> MagicMock:
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_current_price = AsyncMock(return_value=(50000.0, 1.0, 0.0))
|
||||||
|
broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"tot_evlu_amt": "10000000",
|
||||||
|
"dnca_tot_amt": "5000000",
|
||||||
|
"pchs_amt_smtl_amt": "9500000",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
return broker
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_market(self) -> MagicMock:
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "Korea"
|
||||||
|
market.code = "KR"
|
||||||
|
market.exchange_code = "KRX"
|
||||||
|
market.is_domestic = True
|
||||||
|
return market
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_telegram(self) -> MagicMock:
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.notify_trade_execution = AsyncMock()
|
||||||
|
telegram.notify_scenario_matched = AsyncMock()
|
||||||
|
telegram.notify_fat_finger = AsyncMock()
|
||||||
|
return telegram
|
||||||
|
|
||||||
|
def _make_buy_match_with_confidence(
|
||||||
|
self, confidence: int, stock_code: str = "005930"
|
||||||
|
) -> ScenarioMatch:
|
||||||
|
from src.strategy.models import StockScenario
|
||||||
|
scenario = StockScenario(
|
||||||
|
condition=StockCondition(rsi_below=30),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=confidence,
|
||||||
|
allocation_pct=10.0,
|
||||||
|
)
|
||||||
|
return ScenarioMatch(
|
||||||
|
stock_code=stock_code,
|
||||||
|
matched_scenario=scenario,
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=confidence,
|
||||||
|
rationale="Test buy",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_playbook_with_outlook(
|
||||||
|
self, outlook_str: str, market: str = "KR"
|
||||||
|
) -> DayPlaybook:
|
||||||
|
from src.strategy.models import MarketOutlook
|
||||||
|
outlook_map = {
|
||||||
|
"bearish": MarketOutlook.BEARISH,
|
||||||
|
"bullish": MarketOutlook.BULLISH,
|
||||||
|
"neutral": MarketOutlook.NEUTRAL,
|
||||||
|
"neutral_to_bullish": MarketOutlook.NEUTRAL_TO_BULLISH,
|
||||||
|
"neutral_to_bearish": MarketOutlook.NEUTRAL_TO_BEARISH,
|
||||||
|
}
|
||||||
|
return DayPlaybook(
|
||||||
|
date=date(2026, 2, 20),
|
||||||
|
market=market,
|
||||||
|
market_outlook=outlook_map[outlook_str],
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bearish_outlook_raises_buy_confidence_threshold(
|
||||||
|
self,
|
||||||
|
mock_broker: MagicMock,
|
||||||
|
mock_market: MagicMock,
|
||||||
|
mock_telegram: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""BUY with confidence 85 should be suppressed to HOLD in bearish market."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(85))
|
||||||
|
playbook = self._make_playbook_with_outlook("bearish")
|
||||||
|
|
||||||
|
decision_logger = MagicMock()
|
||||||
|
decision_logger.log_decision = MagicMock(return_value="decision-id")
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=MagicMock(),
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=playbook,
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=mock_telegram,
|
||||||
|
market=mock_market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# HOLD should be logged (not BUY) — check decision_logger was called with HOLD
|
||||||
|
call_args = decision_logger.log_decision.call_args
|
||||||
|
assert call_args is not None
|
||||||
|
assert call_args.kwargs["action"] == "HOLD"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bearish_outlook_allows_high_confidence_buy(
|
||||||
|
self,
|
||||||
|
mock_broker: MagicMock,
|
||||||
|
mock_market: MagicMock,
|
||||||
|
mock_telegram: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""BUY with confidence 92 should proceed in bearish market (threshold=90)."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(92))
|
||||||
|
playbook = self._make_playbook_with_outlook("bearish")
|
||||||
|
risk = MagicMock()
|
||||||
|
risk.validate_order = MagicMock()
|
||||||
|
|
||||||
|
decision_logger = MagicMock()
|
||||||
|
decision_logger.log_decision = MagicMock(return_value="decision-id")
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=MagicMock(),
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=playbook,
|
||||||
|
risk=risk,
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=mock_telegram,
|
||||||
|
market=mock_market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
call_args = decision_logger.log_decision.call_args
|
||||||
|
assert call_args is not None
|
||||||
|
assert call_args.kwargs["action"] == "BUY"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bullish_outlook_lowers_buy_confidence_threshold(
|
||||||
|
self,
|
||||||
|
mock_broker: MagicMock,
|
||||||
|
mock_market: MagicMock,
|
||||||
|
mock_telegram: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""BUY with confidence 77 should proceed in bullish market (threshold=75)."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(77))
|
||||||
|
playbook = self._make_playbook_with_outlook("bullish")
|
||||||
|
risk = MagicMock()
|
||||||
|
risk.validate_order = MagicMock()
|
||||||
|
|
||||||
|
decision_logger = MagicMock()
|
||||||
|
decision_logger.log_decision = MagicMock(return_value="decision-id")
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=MagicMock(),
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=playbook,
|
||||||
|
risk=risk,
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=mock_telegram,
|
||||||
|
market=mock_market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
call_args = decision_logger.log_decision.call_args
|
||||||
|
assert call_args is not None
|
||||||
|
assert call_args.kwargs["action"] == "BUY"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bullish_outlook_suppresses_very_low_confidence_buy(
|
||||||
|
self,
|
||||||
|
mock_broker: MagicMock,
|
||||||
|
mock_market: MagicMock,
|
||||||
|
mock_telegram: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""BUY with confidence 70 should be suppressed even in bullish market (threshold=75)."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(70))
|
||||||
|
playbook = self._make_playbook_with_outlook("bullish")
|
||||||
|
|
||||||
|
decision_logger = MagicMock()
|
||||||
|
decision_logger.log_decision = MagicMock(return_value="decision-id")
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=MagicMock(),
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=playbook,
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=mock_telegram,
|
||||||
|
market=mock_market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
call_args = decision_logger.log_decision.call_args
|
||||||
|
assert call_args is not None
|
||||||
|
assert call_args.kwargs["action"] == "HOLD"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_neutral_outlook_uses_default_threshold(
|
||||||
|
self,
|
||||||
|
mock_broker: MagicMock,
|
||||||
|
mock_market: MagicMock,
|
||||||
|
mock_telegram: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""BUY with confidence 82 should proceed in neutral market (default=80)."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(82))
|
||||||
|
playbook = self._make_playbook_with_outlook("neutral")
|
||||||
|
risk = MagicMock()
|
||||||
|
risk.validate_order = MagicMock()
|
||||||
|
|
||||||
|
decision_logger = MagicMock()
|
||||||
|
decision_logger.log_decision = MagicMock(return_value="decision-id")
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=MagicMock(),
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=playbook,
|
||||||
|
risk=risk,
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=mock_telegram,
|
||||||
|
market=mock_market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
call_args = decision_logger.log_decision.call_args
|
||||||
|
assert call_args is not None
|
||||||
|
assert call_args.kwargs["action"] == "BUY"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_buy_suppressed_when_open_position_exists() -> None:
|
||||||
|
"""BUY should be suppressed when an open position already exists for the stock."""
|
||||||
|
db_conn = init_db(":memory:")
|
||||||
|
decision_logger = DecisionLogger(db_conn)
|
||||||
|
|
||||||
|
# 기존 BUY 포지션 DB에 기록 (중복 매수 상황)
|
||||||
|
buy_decision_id = decision_logger.log_decision(
|
||||||
|
stock_code="NP",
|
||||||
|
market="US",
|
||||||
|
exchange_code="AMS",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="initial entry",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
log_trade(
|
||||||
|
conn=db_conn,
|
||||||
|
stock_code="NP",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="initial entry",
|
||||||
|
quantity=10,
|
||||||
|
price=50.0,
|
||||||
|
market="US",
|
||||||
|
exchange_code="AMS",
|
||||||
|
decision_id=buy_decision_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
|
||||||
|
overseas_broker = MagicMock()
|
||||||
|
overseas_broker.get_overseas_price = AsyncMock(
|
||||||
|
return_value={"output": {"last": "51.0", "rate": "2.0", "high": "52.0", "low": "50.0", "tvol": "1000000"}}
|
||||||
|
)
|
||||||
|
overseas_broker.get_overseas_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output1": [],
|
||||||
|
"output2": [{"frcr_dncl_amt_2": "10000", "frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=_make_buy_match(stock_code="NP"))
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "United States"
|
||||||
|
market.code = "US"
|
||||||
|
market.exchange_code = "AMS"
|
||||||
|
market.is_domestic = False
|
||||||
|
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.notify_trade_execution = AsyncMock()
|
||||||
|
telegram.notify_fat_finger = AsyncMock()
|
||||||
|
telegram.notify_circuit_breaker = AsyncMock()
|
||||||
|
telegram.notify_scenario_matched = AsyncMock()
|
||||||
|
|
||||||
|
await trading_cycle(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=overseas_broker,
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=_make_playbook(market="US"),
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=db_conn,
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(
|
||||||
|
get_latest_timeframe=MagicMock(return_value=None),
|
||||||
|
set_context=MagicMock(),
|
||||||
|
),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=telegram,
|
||||||
|
market=market,
|
||||||
|
stock_code="NP",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 이미 보유 중이므로 주문이 실행되지 않아야 함
|
||||||
|
broker.send_order.assert_not_called()
|
||||||
|
overseas_broker.send_overseas_order.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_buy_proceeds_when_no_open_position() -> None:
|
||||||
|
"""BUY should proceed normally when no open position exists for the stock."""
|
||||||
|
db_conn = init_db(":memory:")
|
||||||
|
decision_logger = DecisionLogger(db_conn)
|
||||||
|
# DB가 비어있는 상태 — 기존 포지션 없음
|
||||||
|
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
|
||||||
|
overseas_broker = MagicMock()
|
||||||
|
overseas_broker.get_overseas_price = AsyncMock(
|
||||||
|
return_value={"output": {"last": "100.0", "rate": "1.0", "high": "101.0", "low": "99.0", "tvol": "500000"}}
|
||||||
|
)
|
||||||
|
overseas_broker.get_overseas_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output1": [],
|
||||||
|
"output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "0"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=_make_buy_match(stock_code="KNRX"))
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "United States"
|
||||||
|
market.code = "US"
|
||||||
|
market.exchange_code = "NAS"
|
||||||
|
market.is_domestic = False
|
||||||
|
|
||||||
|
risk = MagicMock()
|
||||||
|
risk.validate_order = MagicMock()
|
||||||
|
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.notify_trade_execution = AsyncMock()
|
||||||
|
telegram.notify_fat_finger = AsyncMock()
|
||||||
|
telegram.notify_circuit_breaker = AsyncMock()
|
||||||
|
telegram.notify_scenario_matched = AsyncMock()
|
||||||
|
|
||||||
|
await trading_cycle(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=overseas_broker,
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=_make_playbook(market="US"),
|
||||||
|
risk=risk,
|
||||||
|
db_conn=db_conn,
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(
|
||||||
|
get_latest_timeframe=MagicMock(return_value=None),
|
||||||
|
set_context=MagicMock(),
|
||||||
|
),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=telegram,
|
||||||
|
market=market,
|
||||||
|
stock_code="KNRX",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 포지션이 없으므로 해외 주문이 실행되어야 함
|
||||||
|
overseas_broker.send_overseas_order.assert_called_once()
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ class TestSendOverseasOrder:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sell_limit_order(self, overseas_broker: OverseasBroker) -> None:
|
async def test_sell_limit_order(self, overseas_broker: OverseasBroker) -> None:
|
||||||
"""Limit sell order should use VTTT1006U and ORD_DVSN=00."""
|
"""Limit sell order should use VTTT1001U and ORD_DVSN=00."""
|
||||||
mock_resp = AsyncMock()
|
mock_resp = AsyncMock()
|
||||||
mock_resp.status = 200
|
mock_resp.status = 200
|
||||||
mock_resp.json = AsyncMock(return_value={"rt_cd": "0"})
|
mock_resp.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
@@ -428,7 +428,7 @@ class TestSendOverseasOrder:
|
|||||||
result = await overseas_broker.send_overseas_order("NYSE", "MSFT", "SELL", 5, price=350.0)
|
result = await overseas_broker.send_overseas_order("NYSE", "MSFT", "SELL", 5, price=350.0)
|
||||||
assert result["rt_cd"] == "0"
|
assert result["rt_cd"] == "0"
|
||||||
|
|
||||||
overseas_broker._broker._auth_headers.assert_called_with("VTTT1006U")
|
overseas_broker._broker._auth_headers.assert_called_with("VTTT1001U")
|
||||||
|
|
||||||
call_args = mock_session.post.call_args
|
call_args = mock_session.post.call_args
|
||||||
body = call_args[1]["json"]
|
body = call_args[1]["json"]
|
||||||
|
|||||||
@@ -830,3 +830,171 @@ class TestSmartFallbackPlaybook:
|
|||||||
]
|
]
|
||||||
assert len(buy_scenarios) == 1
|
assert len(buy_scenarios) == 1
|
||||||
assert buy_scenarios[0].condition.volume_ratio_above == 2.0 # VOL_MULTIPLIER default
|
assert buy_scenarios[0].condition.volume_ratio_above == 2.0 # VOL_MULTIPLIER default
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Holdings in prompt (#170)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHoldingsInPrompt:
|
||||||
|
"""Tests for current_holdings parameter in generate_playbook / _build_prompt."""
|
||||||
|
|
||||||
|
def _make_holdings(self) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"stock_code": "005930",
|
||||||
|
"name": "Samsung",
|
||||||
|
"qty": 10,
|
||||||
|
"entry_price": 71000.0,
|
||||||
|
"unrealized_pnl_pct": 2.3,
|
||||||
|
"holding_days": 3,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_build_prompt_includes_holdings_section(self) -> None:
|
||||||
|
"""Prompt should contain a Current Holdings section when holdings are given."""
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
holdings = self._make_holdings()
|
||||||
|
|
||||||
|
prompt = planner._build_prompt(
|
||||||
|
"KR",
|
||||||
|
candidates,
|
||||||
|
context_data={},
|
||||||
|
self_market_scorecard=None,
|
||||||
|
cross_market=None,
|
||||||
|
current_holdings=holdings,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "## Current Holdings" in prompt
|
||||||
|
assert "005930" in prompt
|
||||||
|
assert "+2.30%" in prompt
|
||||||
|
assert "보유 3일" in prompt
|
||||||
|
|
||||||
|
def test_build_prompt_no_holdings_omits_section(self) -> None:
|
||||||
|
"""Prompt should NOT contain a Current Holdings section when holdings=None."""
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
prompt = planner._build_prompt(
|
||||||
|
"KR",
|
||||||
|
candidates,
|
||||||
|
context_data={},
|
||||||
|
self_market_scorecard=None,
|
||||||
|
cross_market=None,
|
||||||
|
current_holdings=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "## Current Holdings" not in prompt
|
||||||
|
|
||||||
|
def test_build_prompt_empty_holdings_omits_section(self) -> None:
|
||||||
|
"""Empty list should also omit the holdings section."""
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
prompt = planner._build_prompt(
|
||||||
|
"KR",
|
||||||
|
candidates,
|
||||||
|
context_data={},
|
||||||
|
self_market_scorecard=None,
|
||||||
|
cross_market=None,
|
||||||
|
current_holdings=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "## Current Holdings" not in prompt
|
||||||
|
|
||||||
|
def test_build_prompt_holdings_instruction_included(self) -> None:
|
||||||
|
"""Prompt should include instruction to generate scenarios for held stocks."""
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
holdings = self._make_holdings()
|
||||||
|
|
||||||
|
prompt = planner._build_prompt(
|
||||||
|
"KR",
|
||||||
|
candidates,
|
||||||
|
context_data={},
|
||||||
|
self_market_scorecard=None,
|
||||||
|
cross_market=None,
|
||||||
|
current_holdings=holdings,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "005930" in prompt
|
||||||
|
assert "SELL/HOLD" in prompt
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_playbook_passes_holdings_to_prompt(self) -> None:
|
||||||
|
"""generate_playbook should pass current_holdings through to the prompt."""
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
holdings = self._make_holdings()
|
||||||
|
|
||||||
|
# Capture the actual prompt sent to Gemini
|
||||||
|
captured_prompts: list[str] = []
|
||||||
|
original_decide = planner._gemini.decide
|
||||||
|
|
||||||
|
async def capture_and_call(data: dict) -> TradeDecision:
|
||||||
|
captured_prompts.append(data.get("prompt_override", ""))
|
||||||
|
return await original_decide(data)
|
||||||
|
|
||||||
|
planner._gemini.decide = capture_and_call # type: ignore[method-assign]
|
||||||
|
|
||||||
|
await planner.generate_playbook(
|
||||||
|
"KR", candidates, today=date(2026, 2, 8), current_holdings=holdings
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(captured_prompts) == 1
|
||||||
|
assert "## Current Holdings" in captured_prompts[0]
|
||||||
|
assert "005930" in captured_prompts[0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_holdings_stock_allowed_in_parse_response(self) -> None:
|
||||||
|
"""Holdings stocks not in candidates list should be accepted in the response."""
|
||||||
|
holding_code = "000660" # Not in candidates
|
||||||
|
stocks = [
|
||||||
|
{
|
||||||
|
"stock_code": "005930", # candidate
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"condition": {"rsi_below": 30},
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 85,
|
||||||
|
"rationale": "oversold",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stock_code": holding_code, # holding only
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"condition": {"price_change_pct_below": -2.0},
|
||||||
|
"action": "SELL",
|
||||||
|
"confidence": 90,
|
||||||
|
"rationale": "stop-loss",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
planner = _make_planner(gemini_response=_gemini_response_json(stocks=stocks))
|
||||||
|
candidates = [_candidate()] # only 005930
|
||||||
|
holdings = [
|
||||||
|
{
|
||||||
|
"stock_code": holding_code,
|
||||||
|
"name": "SK Hynix",
|
||||||
|
"qty": 5,
|
||||||
|
"entry_price": 180000.0,
|
||||||
|
"unrealized_pnl_pct": -1.5,
|
||||||
|
"holding_days": 7,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
pb = await planner.generate_playbook(
|
||||||
|
"KR",
|
||||||
|
candidates,
|
||||||
|
today=date(2026, 2, 8),
|
||||||
|
current_holdings=holdings,
|
||||||
|
)
|
||||||
|
|
||||||
|
codes = [sp.stock_code for sp in pb.stock_playbooks]
|
||||||
|
assert "005930" in codes
|
||||||
|
assert holding_code in codes
|
||||||
|
|||||||
@@ -440,3 +440,135 @@ class TestEvaluate:
|
|||||||
assert result.action == ScenarioAction.BUY
|
assert result.action == ScenarioAction.BUY
|
||||||
assert result.match_details["rsi"] == 25.0
|
assert result.match_details["rsi"] == 25.0
|
||||||
assert isinstance(result.match_details["rsi"], float)
|
assert isinstance(result.match_details["rsi"], float)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Position-aware condition tests (#171)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPositionAwareConditions:
|
||||||
|
"""Tests for unrealized_pnl_pct and holding_days condition fields."""
|
||||||
|
|
||||||
|
def test_evaluate_condition_unrealized_pnl_above_matches(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""unrealized_pnl_pct_above should match when P&L exceeds threshold."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_above=3.0)
|
||||||
|
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 5.0}) is True
|
||||||
|
|
||||||
|
def test_evaluate_condition_unrealized_pnl_above_no_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""unrealized_pnl_pct_above should NOT match when P&L is below threshold."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_above=3.0)
|
||||||
|
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 2.0}) is False
|
||||||
|
|
||||||
|
def test_evaluate_condition_unrealized_pnl_below_matches(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""unrealized_pnl_pct_below should match when P&L is under threshold."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
|
||||||
|
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -3.5}) is True
|
||||||
|
|
||||||
|
def test_evaluate_condition_unrealized_pnl_below_no_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""unrealized_pnl_pct_below should NOT match when P&L is above threshold."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
|
||||||
|
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -1.0}) is False
|
||||||
|
|
||||||
|
def test_evaluate_condition_holding_days_above_matches(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""holding_days_above should match when position held longer than threshold."""
|
||||||
|
condition = StockCondition(holding_days_above=5)
|
||||||
|
assert engine.evaluate_condition(condition, {"holding_days": 7}) is True
|
||||||
|
|
||||||
|
def test_evaluate_condition_holding_days_above_no_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""holding_days_above should NOT match when position held shorter."""
|
||||||
|
condition = StockCondition(holding_days_above=5)
|
||||||
|
assert engine.evaluate_condition(condition, {"holding_days": 3}) is False
|
||||||
|
|
||||||
|
def test_evaluate_condition_holding_days_below_matches(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""holding_days_below should match when position held fewer days."""
|
||||||
|
condition = StockCondition(holding_days_below=3)
|
||||||
|
assert engine.evaluate_condition(condition, {"holding_days": 1}) is True
|
||||||
|
|
||||||
|
def test_evaluate_condition_holding_days_below_no_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""holding_days_below should NOT match when held more days."""
|
||||||
|
condition = StockCondition(holding_days_below=3)
|
||||||
|
assert engine.evaluate_condition(condition, {"holding_days": 5}) is False
|
||||||
|
|
||||||
|
def test_combined_pnl_and_holding_days(self, engine: ScenarioEngine) -> None:
|
||||||
|
"""Combined position-aware conditions should AND-evaluate correctly."""
|
||||||
|
condition = StockCondition(
|
||||||
|
unrealized_pnl_pct_above=3.0,
|
||||||
|
holding_days_above=5,
|
||||||
|
)
|
||||||
|
# Both met → match
|
||||||
|
assert engine.evaluate_condition(
|
||||||
|
condition,
|
||||||
|
{"unrealized_pnl_pct": 4.5, "holding_days": 7},
|
||||||
|
) is True
|
||||||
|
# Only pnl met → no match
|
||||||
|
assert engine.evaluate_condition(
|
||||||
|
condition,
|
||||||
|
{"unrealized_pnl_pct": 4.5, "holding_days": 3},
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_missing_unrealized_pnl_does_not_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""Missing unrealized_pnl_pct key should not match the condition."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_above=3.0)
|
||||||
|
assert engine.evaluate_condition(condition, {}) is False
|
||||||
|
|
||||||
|
def test_missing_holding_days_does_not_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""Missing holding_days key should not match the condition."""
|
||||||
|
condition = StockCondition(holding_days_above=5)
|
||||||
|
assert engine.evaluate_condition(condition, {}) is False
|
||||||
|
|
||||||
|
def test_match_details_includes_position_fields(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""match_details should include position fields when condition specifies them."""
|
||||||
|
pb = _playbook(
|
||||||
|
scenarios=[
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(unrealized_pnl_pct_above=3.0),
|
||||||
|
action=ScenarioAction.SELL,
|
||||||
|
confidence=90,
|
||||||
|
rationale="Take profit",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = engine.evaluate(
|
||||||
|
pb,
|
||||||
|
"005930",
|
||||||
|
{"unrealized_pnl_pct": 5.0},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert result.action == ScenarioAction.SELL
|
||||||
|
assert "unrealized_pnl_pct" in result.match_details
|
||||||
|
assert result.match_details["unrealized_pnl_pct"] == 5.0
|
||||||
|
|
||||||
|
def test_position_conditions_parse_from_planner(self) -> None:
|
||||||
|
"""StockCondition should accept and store new fields from JSON parsing."""
|
||||||
|
condition = StockCondition(
|
||||||
|
unrealized_pnl_pct_above=3.0,
|
||||||
|
unrealized_pnl_pct_below=None,
|
||||||
|
holding_days_above=5,
|
||||||
|
holding_days_below=None,
|
||||||
|
)
|
||||||
|
assert condition.unrealized_pnl_pct_above == 3.0
|
||||||
|
assert condition.holding_days_above == 5
|
||||||
|
assert condition.has_any_condition() is True
|
||||||
|
|||||||
@@ -350,6 +350,42 @@ class TestSmartVolatilityScanner:
|
|||||||
assert [c.stock_code for c in candidates] == ["ABCD"]
|
assert [c.stock_code for c in candidates] == ["ABCD"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestImpliedRSIFormula:
|
||||||
|
"""Test the implied_rsi formula in SmartVolatilityScanner (issue #181)."""
|
||||||
|
|
||||||
|
def test_neutral_change_gives_neutral_rsi(self) -> None:
|
||||||
|
"""0% change → implied_rsi = 50 (neutral)."""
|
||||||
|
# formula: 50 + (change_rate * 2.0)
|
||||||
|
rsi = max(0.0, min(100.0, 50.0 + (0.0 * 2.0)))
|
||||||
|
assert rsi == 50.0
|
||||||
|
|
||||||
|
def test_10pct_change_gives_rsi_70(self) -> None:
|
||||||
|
"""10% upward change → implied_rsi = 70 (momentum signal)."""
|
||||||
|
rsi = max(0.0, min(100.0, 50.0 + (10.0 * 2.0)))
|
||||||
|
assert rsi == 70.0
|
||||||
|
|
||||||
|
def test_minus_10pct_gives_rsi_30(self) -> None:
|
||||||
|
"""-10% change → implied_rsi = 30 (oversold signal)."""
|
||||||
|
rsi = max(0.0, min(100.0, 50.0 + (-10.0 * 2.0)))
|
||||||
|
assert rsi == 30.0
|
||||||
|
|
||||||
|
def test_saturation_at_25pct(self) -> None:
|
||||||
|
"""Saturation occurs at >=25% change (not 12.5% as with old coefficient 4.0)."""
|
||||||
|
rsi_12pct = max(0.0, min(100.0, 50.0 + (12.5 * 2.0)))
|
||||||
|
rsi_25pct = max(0.0, min(100.0, 50.0 + (25.0 * 2.0)))
|
||||||
|
rsi_30pct = max(0.0, min(100.0, 50.0 + (30.0 * 2.0)))
|
||||||
|
# At 12.5% change: RSI = 75 (not 100, unlike old formula)
|
||||||
|
assert rsi_12pct == 75.0
|
||||||
|
# At 25%+ saturation
|
||||||
|
assert rsi_25pct == 100.0
|
||||||
|
assert rsi_30pct == 100.0 # Capped
|
||||||
|
|
||||||
|
def test_negative_saturation(self) -> None:
|
||||||
|
"""Saturation at -25% gives RSI = 0."""
|
||||||
|
rsi = max(0.0, min(100.0, 50.0 + (-25.0 * 2.0)))
|
||||||
|
assert rsi == 0.0
|
||||||
|
|
||||||
|
|
||||||
class TestRSICalculation:
|
class TestRSICalculation:
|
||||||
"""Test RSI calculation in VolatilityAnalyzer."""
|
"""Test RSI calculation in VolatilityAnalyzer."""
|
||||||
|
|
||||||
|
|||||||
@@ -876,6 +876,54 @@ class TestGetUpdates:
|
|||||||
|
|
||||||
assert updates == []
|
assert updates == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_updates_409_stops_polling(self) -> None:
|
||||||
|
"""409 Conflict response stops the poller (_running = False) and returns empty list."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
handler._running = True # simulate active poller
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 409
|
||||||
|
mock_resp.text = AsyncMock(
|
||||||
|
return_value='{"ok":false,"error_code":409,"description":"Conflict"}'
|
||||||
|
)
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
|
||||||
|
updates = await handler._get_updates()
|
||||||
|
|
||||||
|
assert updates == []
|
||||||
|
assert handler._running is False # poller stopped
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_loop_exits_after_409(self) -> None:
|
||||||
|
"""_poll_loop exits naturally after _running is set to False by a 409 response."""
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def mock_get_updates_409() -> list[dict]:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
# Simulate 409 stopping the poller
|
||||||
|
handler._running = False
|
||||||
|
return []
|
||||||
|
|
||||||
|
handler._get_updates = mock_get_updates_409 # type: ignore[method-assign]
|
||||||
|
|
||||||
|
handler._running = True
|
||||||
|
task = _asyncio.create_task(handler._poll_loop())
|
||||||
|
await _asyncio.wait_for(task, timeout=2.0)
|
||||||
|
|
||||||
|
# _get_updates called exactly once, then loop exited
|
||||||
|
assert call_count == 1
|
||||||
|
assert handler._running is False
|
||||||
|
|
||||||
|
|
||||||
class TestCommandWithArgs:
|
class TestCommandWithArgs:
|
||||||
"""Test register_command_with_args and argument dispatch."""
|
"""Test register_command_with_args and argument dispatch."""
|
||||||
|
|||||||
Reference in New Issue
Block a user