Compare commits
9 Commits
847456e0af
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b20251de0 | ||
| bffe6e9288 | |||
|
|
0146d1bf8a | ||
| 497564e75c | |||
|
|
988a56c07c | ||
| c9f1345e3c | |||
|
|
8c492eae3a | ||
| 271c592a46 | |||
|
|
a063bd9d10 |
@@ -346,8 +346,10 @@ class GeminiClient:
|
|||||||
# Validate required fields
|
# Validate required fields
|
||||||
if not all(k in data for k in ("action", "confidence", "rationale")):
|
if not all(k in data for k in ("action", "confidence", "rationale")):
|
||||||
logger.warning("Missing fields in Gemini response — defaulting to HOLD")
|
logger.warning("Missing fields in Gemini response — defaulting to HOLD")
|
||||||
|
# Preserve raw text in rationale so prompt_override callers (e.g. pre_market_planner)
|
||||||
|
# can extract their own JSON format from decision.rationale (#245)
|
||||||
return TradeDecision(
|
return TradeDecision(
|
||||||
action="HOLD", confidence=0, rationale="Missing required fields"
|
action="HOLD", confidence=0, rationale=raw
|
||||||
)
|
)
|
||||||
|
|
||||||
action = str(data["action"]).upper()
|
action = str(data["action"]).upper()
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ class PromptOptimizer:
|
|||||||
# Minimal instructions
|
# Minimal instructions
|
||||||
prompt = (
|
prompt = (
|
||||||
f"{market_name} trader. Analyze:\n{data_str}\n\n"
|
f"{market_name} trader. Analyze:\n{data_str}\n\n"
|
||||||
'Return JSON: {"act":"BUY"|"SELL"|"HOLD","conf":<0-100>,"reason":"<text>"}\n'
|
'Return JSON: {"action":"BUY"|"SELL"|"HOLD","confidence":<0-100>,"rationale":"<text>"}\n'
|
||||||
"Rules: act=BUY/SELL/HOLD, conf=0-100, reason=concise. No markdown."
|
"Rules: action=BUY/SELL/HOLD, confidence=0-100, rationale=concise. No markdown."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Data only (for cached contexts where instructions are known)
|
# Data only (for cached contexts where instructions are known)
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ class KISBroker:
|
|||||||
"fid_cond_mrkt_div_code": "J",
|
"fid_cond_mrkt_div_code": "J",
|
||||||
"fid_cond_scr_div_code": "20170",
|
"fid_cond_scr_div_code": "20170",
|
||||||
"fid_input_iscd": "0000",
|
"fid_input_iscd": "0000",
|
||||||
"fid_rank_sort_cls_code": "0000",
|
"fid_rank_sort_cls_code": "0",
|
||||||
"fid_input_cnt_1": str(limit),
|
"fid_input_cnt_1": str(limit),
|
||||||
"fid_prc_cls_code": "0",
|
"fid_prc_cls_code": "0",
|
||||||
"fid_input_price_1": "0",
|
"fid_input_price_1": "0",
|
||||||
@@ -466,7 +466,7 @@ class KISBroker:
|
|||||||
rankings = []
|
rankings = []
|
||||||
for item in data.get("output", [])[:limit]:
|
for item in data.get("output", [])[:limit]:
|
||||||
rankings.append({
|
rankings.append({
|
||||||
"stock_code": item.get("mksc_shrn_iscd", ""),
|
"stock_code": item.get("stck_shrn_iscd") or item.get("mksc_shrn_iscd", ""),
|
||||||
"name": item.get("hts_kor_isnm", ""),
|
"name": item.get("hts_kor_isnm", ""),
|
||||||
"price": _safe_float(item.get("stck_prpr", "0")),
|
"price": _safe_float(item.get("stck_prpr", "0")),
|
||||||
"volume": _safe_float(item.get("acml_vol", "0")),
|
"volume": _safe_float(item.get("acml_vol", "0")),
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ class OverseasBroker:
|
|||||||
"AUTH": "",
|
"AUTH": "",
|
||||||
"EXCD": ranking_excd,
|
"EXCD": ranking_excd,
|
||||||
"NDAY": "0",
|
"NDAY": "0",
|
||||||
"GUBN": "1",
|
"GUBN": "0", # 0=전체(상승+하락), 1=상승만 — 변동성 스캐너는 전체 필요
|
||||||
"VOL_RANG": "0",
|
"VOL_RANG": "0",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ from fastapi import FastAPI, HTTPException, Query
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
|
||||||
def create_dashboard_app(db_path: str) -> FastAPI:
|
def create_dashboard_app(db_path: str, mode: str = "paper") -> FastAPI:
|
||||||
"""Create dashboard FastAPI app bound to a SQLite database path."""
|
"""Create dashboard FastAPI app bound to a SQLite database path."""
|
||||||
app = FastAPI(title="The Ouroboros Dashboard", version="1.0.0")
|
app = FastAPI(title="The Ouroboros Dashboard", version="1.0.0")
|
||||||
app.state.db_path = db_path
|
app.state.db_path = db_path
|
||||||
|
app.state.mode = mode
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index() -> FileResponse:
|
def index() -> FileResponse:
|
||||||
@@ -111,6 +112,7 @@ def create_dashboard_app(db_path: str) -> FastAPI:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"date": today,
|
"date": today,
|
||||||
|
"mode": mode,
|
||||||
"markets": market_status,
|
"markets": market_status,
|
||||||
"totals": {
|
"totals": {
|
||||||
"trade_count": total_trades,
|
"trade_count": total_trades,
|
||||||
|
|||||||
@@ -43,6 +43,19 @@
|
|||||||
font-size: 12px; transition: border-color 0.2s;
|
font-size: 12px; transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
|
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.mode-badge {
|
||||||
|
padding: 3px 10px; border-radius: 5px; font-size: 12px; font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.mode-badge.live {
|
||||||
|
background: rgba(224, 85, 85, 0.15); color: var(--red);
|
||||||
|
border: 1px solid rgba(224, 85, 85, 0.4);
|
||||||
|
animation: pulse-warn 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.mode-badge.paper {
|
||||||
|
background: rgba(232, 160, 64, 0.15); color: var(--warn);
|
||||||
|
border: 1px solid rgba(232, 160, 64, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* CB Gauge */
|
/* CB Gauge */
|
||||||
.cb-gauge-wrap {
|
.cb-gauge-wrap {
|
||||||
@@ -225,6 +238,7 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1>🐍 The Ouroboros</h1>
|
<h1>🐍 The Ouroboros</h1>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<span class="mode-badge" id="mode-badge">--</span>
|
||||||
<div class="cb-gauge-wrap" id="cb-gauge" title="Circuit Breaker">
|
<div class="cb-gauge-wrap" id="cb-gauge" title="Circuit Breaker">
|
||||||
<span class="cb-dot unknown" id="cb-dot"></span>
|
<span class="cb-dot unknown" id="cb-dot"></span>
|
||||||
<span id="cb-label">CB --</span>
|
<span id="cb-label">CB --</span>
|
||||||
@@ -512,9 +526,22 @@
|
|||||||
}
|
}
|
||||||
document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}건`;
|
document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}건`;
|
||||||
renderCbGauge(d.circuit_breaker);
|
renderCbGauge(d.circuit_breaker);
|
||||||
|
renderModeBadge(d.mode);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderModeBadge(mode) {
|
||||||
|
const el = document.getElementById('mode-badge');
|
||||||
|
if (!el) return;
|
||||||
|
if (mode === 'live') {
|
||||||
|
el.textContent = '🔴 실전투자';
|
||||||
|
el.className = 'mode-badge live';
|
||||||
|
} else {
|
||||||
|
el.textContent = '🟡 모의투자';
|
||||||
|
el.className = 'mode-badge paper';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPerformance() {
|
async function fetchPerformance() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/performance?market=all');
|
const r = await fetch('/api/performance?market=all');
|
||||||
|
|||||||
@@ -2045,7 +2045,7 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
|
|||||||
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, mode=settings.MODE)
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
app,
|
app,
|
||||||
host=settings.DASHBOARD_HOST,
|
host=settings.DASHBOARD_HOST,
|
||||||
|
|||||||
@@ -93,9 +93,21 @@ class TestMalformedJsonHandling:
|
|||||||
|
|
||||||
def test_json_with_missing_fields_returns_hold(self, settings):
|
def test_json_with_missing_fields_returns_hold(self, settings):
|
||||||
client = GeminiClient(settings)
|
client = GeminiClient(settings)
|
||||||
decision = client.parse_response('{"action": "BUY"}')
|
raw = '{"action": "BUY"}'
|
||||||
|
decision = client.parse_response(raw)
|
||||||
assert decision.action == "HOLD"
|
assert decision.action == "HOLD"
|
||||||
assert decision.confidence == 0
|
assert decision.confidence == 0
|
||||||
|
# rationale preserves raw so prompt_override callers (e.g. pre_market_planner)
|
||||||
|
# can extract non-TradeDecision JSON from decision.rationale (#245)
|
||||||
|
assert decision.rationale == raw
|
||||||
|
|
||||||
|
def test_non_trade_decision_json_preserves_raw_in_rationale(self, settings):
|
||||||
|
"""Playbook JSON (no action/confidence/rationale) must be preserved for planner."""
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
playbook_json = '{"market_outlook": "neutral", "stocks": []}'
|
||||||
|
decision = client.parse_response(playbook_json)
|
||||||
|
assert decision.action == "HOLD"
|
||||||
|
assert decision.rationale == playbook_json
|
||||||
|
|
||||||
def test_json_with_invalid_action_returns_hold(self, settings):
|
def test_json_with_invalid_action_returns_hold(self, settings):
|
||||||
client = GeminiClient(settings)
|
client = GeminiClient(settings)
|
||||||
|
|||||||
@@ -354,6 +354,8 @@ class TestFetchMarketRankings:
|
|||||||
assert "ranking/fluctuation" in url
|
assert "ranking/fluctuation" in url
|
||||||
assert headers.get("tr_id") == "FHPST01700000"
|
assert headers.get("tr_id") == "FHPST01700000"
|
||||||
assert params.get("fid_cond_scr_div_code") == "20170"
|
assert params.get("fid_cond_scr_div_code") == "20170"
|
||||||
|
# 실전 API는 4자리("0000") 거부 — 1자리("0")여야 한다 (#240)
|
||||||
|
assert params.get("fid_rank_sort_cls_code") == "0"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None:
|
async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None:
|
||||||
@@ -376,6 +378,27 @@ class TestFetchMarketRankings:
|
|||||||
assert result[0]["price"] == 75000.0
|
assert result[0]["price"] == 75000.0
|
||||||
assert result[0]["change_rate"] == 2.5
|
assert result[0]["change_rate"] == 2.5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fluctuation_parses_stck_shrn_iscd(self, broker: KISBroker) -> None:
|
||||||
|
"""실전 API는 mksc_shrn_iscd 대신 stck_shrn_iscd를 반환한다 (#240)."""
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"stck_shrn_iscd": "015260",
|
||||||
|
"hts_kor_isnm": "에이엔피",
|
||||||
|
"stck_prpr": "794",
|
||||||
|
"acml_vol": "4896196",
|
||||||
|
"prdy_ctrt": "29.74",
|
||||||
|
"vol_inrt": "0",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_resp = _make_ranking_mock(items)
|
||||||
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
|
||||||
|
result = await broker.fetch_market_rankings(ranking_type="fluctuation")
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["stock_code"] == "015260"
|
||||||
|
assert result[0]["change_rate"] == 29.74
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# KRX tick unit / round-down helpers (issue #157)
|
# KRX tick unit / round-down helpers (issue #157)
|
||||||
|
|||||||
@@ -413,3 +413,39 @@ def test_status_circuit_breaker_unknown_when_no_data(tmp_path: Path) -> None:
|
|||||||
cb = body["circuit_breaker"]
|
cb = body["circuit_breaker"]
|
||||||
assert cb["status"] == "unknown"
|
assert cb["status"] == "unknown"
|
||||||
assert cb["current_pnl_pct"] is None
|
assert cb["current_pnl_pct"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_mode_paper(tmp_path: Path) -> None:
|
||||||
|
"""mode=paper로 생성하면 status 응답에 mode=paper가 포함돼야 한다."""
|
||||||
|
db_path = tmp_path / "dashboard_test.db"
|
||||||
|
conn = init_db(str(db_path))
|
||||||
|
_seed_db(conn)
|
||||||
|
conn.close()
|
||||||
|
app = create_dashboard_app(str(db_path), mode="paper")
|
||||||
|
get_status = _endpoint(app, "/api/status")
|
||||||
|
body = get_status()
|
||||||
|
assert body["mode"] == "paper"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_mode_live(tmp_path: Path) -> None:
|
||||||
|
"""mode=live로 생성하면 status 응답에 mode=live가 포함돼야 한다."""
|
||||||
|
db_path = tmp_path / "dashboard_test.db"
|
||||||
|
conn = init_db(str(db_path))
|
||||||
|
_seed_db(conn)
|
||||||
|
conn.close()
|
||||||
|
app = create_dashboard_app(str(db_path), mode="live")
|
||||||
|
get_status = _endpoint(app, "/api/status")
|
||||||
|
body = get_status()
|
||||||
|
assert body["mode"] == "live"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_mode_default_paper(tmp_path: Path) -> None:
|
||||||
|
"""mode 파라미터 미전달 시 기본값은 paper여야 한다."""
|
||||||
|
db_path = tmp_path / "dashboard_test.db"
|
||||||
|
conn = init_db(str(db_path))
|
||||||
|
_seed_db(conn)
|
||||||
|
conn.close()
|
||||||
|
app = create_dashboard_app(str(db_path))
|
||||||
|
get_status = _endpoint(app, "/api/status")
|
||||||
|
body = get_status()
|
||||||
|
assert body["mode"] == "paper"
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class TestFetchOverseasRankings:
|
|||||||
assert "/uapi/overseas-stock/v1/ranking/updown-rate" in url
|
assert "/uapi/overseas-stock/v1/ranking/updown-rate" in url
|
||||||
assert params["EXCD"] == "NAS"
|
assert params["EXCD"] == "NAS"
|
||||||
assert params["NDAY"] == "0"
|
assert params["NDAY"] == "0"
|
||||||
assert params["GUBN"] == "1"
|
assert params["GUBN"] == "0" # 0=전체(상승+하락), 변동성 스캐너에 필요
|
||||||
assert params["VOL_RANG"] == "0"
|
assert params["VOL_RANG"] == "0"
|
||||||
|
|
||||||
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000")
|
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000")
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ class TestPromptOptimizer:
|
|||||||
assert len(prompt) < 300
|
assert len(prompt) < 300
|
||||||
assert "005930" in prompt
|
assert "005930" in prompt
|
||||||
assert "75000" in prompt
|
assert "75000" in prompt
|
||||||
|
# Keys must match parse_response expectations (#242)
|
||||||
|
assert '"action"' in prompt
|
||||||
|
assert '"confidence"' in prompt
|
||||||
|
assert '"rationale"' in prompt
|
||||||
|
|
||||||
def test_build_compressed_prompt_no_instructions(self):
|
def test_build_compressed_prompt_no_instructions(self):
|
||||||
"""Test compressed prompt without instructions."""
|
"""Test compressed prompt without instructions."""
|
||||||
|
|||||||
Reference in New Issue
Block a user