Compare commits
8 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ea41cf3c | ||
| af5bfbac24 | |||
|
|
7e9a573390 | ||
| 7dbc48260c | |||
|
|
4b883a4fc4 | ||
|
|
98071a8ee3 | ||
|
|
f2ad270e8b | ||
| 04c73a1a06 |
@@ -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 효율화
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
19
src/main.py
19
src/main.py
@@ -510,6 +510,25 @@ async def trading_cycle(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
|||||||
@@ -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"] == []
|
||||||
|
|||||||
@@ -2848,3 +2848,156 @@ class TestMarketOutlookConfidenceThreshold:
|
|||||||
call_args = decision_logger.log_decision.call_args
|
call_args = decision_logger.log_decision.call_args
|
||||||
assert call_args is not None
|
assert call_args is not None
|
||||||
assert call_args.kwargs["action"] == "BUY"
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user