diff --git a/dashboard-api/main.py b/dashboard-api/main.py
index 41171f6..0f7fe42 100644
--- a/dashboard-api/main.py
+++ b/dashboard-api/main.py
@@ -2110,6 +2110,11 @@ async def proxy_decisions_accuracy(days: int = Query(default=60, ge=7, le=365)):
"""결정안 실측 성과 (score-engine /decisions/accuracy)"""
return await _proxy_get(f"{SCORE_ENGINE_URL}/decisions/accuracy?days={days}")
+@app.get("/api/trade-history")
+async def proxy_trade_history(days: int = Query(default=30, ge=1, le=365)):
+ """모의매매 이력 + 손익 (score-engine /trade/history)"""
+ return await _proxy_get(f"{SCORE_ENGINE_URL}/trade/history?days={days}")
+
async def _enrich_kr_names(rows):
"""rows의 kr_code/stock_code에 dart_corps.corp_name을 kr_name으로 첨부"""
diff --git a/score-engine/main.py b/score-engine/main.py
index da0a74f..ff09f7d 100644
--- a/score-engine/main.py
+++ b/score-engine/main.py
@@ -5867,6 +5867,7 @@ async def ensure_trading_tables():
# 자동매매 안전 설정 — 보수적 기본값
TRADE_SETTINGS = {
"enabled": True, # 자동매매 ON/OFF 토글
+ "auto_execute": True, # 제안 즉시 자동 체결(모의). False면 텔레그램 버튼 승인 대기
"max_position_pct": 10.0, # 종목당 자본 ≤ 10%
"daily_loss_limit_pct": -3.0, # 일일 손실 -3% 도달 시 매매 중단
"max_orders_per_day": 10,
@@ -6190,59 +6191,101 @@ async def trade_sell_scan():
return {"checked": len(codes), "proposed": len(results), "results": results}
+async def _fill_order(conn, order_id: int, auto: bool = False) -> dict:
+ """주문 체결(모의=DB 기록, 실제 브로커 전송 없음). auto=True면 자동매매 체결."""
+ order = await conn.fetchrow(
+ "SELECT * FROM trading_orders WHERE id=$1 AND status='pending'", order_id)
+ if not order:
+ return {"error": "주문 없음 또는 이미 처리됨"}
+ if order["expires_at"] and order["expires_at"] < datetime.now(timezone.utc):
+ await conn.execute(
+ "UPDATE trading_orders SET status='expired' WHERE id=$1", order_id)
+ return {"error": "주문 만료"}
+ tag = "자동체결" if auto else "체결"
+ if order["side"] == "buy":
+ await conn.execute("""
+ INSERT INTO user_portfolio (stock_code, stock_name, buy_price, qty, memo)
+ VALUES ($1, $2, $3, $4, $5)
+ """, order["stock_code"], order["stock_name"], order["price"],
+ order["qty"], f"자동매매 #{order_id} (모의)")
+ msg = (f"✅ 매수 {tag} #{order_id} (모의)\n"
+ f"{order['stock_name']}({order['stock_code']}) "
+ f"{order['qty']}주 × {order['price']:,}원")
+ else: # sell
+ held = await conn.fetchrow("""
+ SELECT id, buy_price, qty FROM user_portfolio
+ WHERE stock_code=$1 AND active=true LIMIT 1
+ """, order["stock_code"])
+ realized = 0
+ if held:
+ realized = (order["price"] - held["buy_price"]) * order["qty"]
+ await conn.execute(
+ "UPDATE user_portfolio SET active=false WHERE id=$1", held["id"])
+ await conn.execute("""
+ INSERT INTO trading_daily_pnl (dt, realized_pnl, trades_count)
+ VALUES (CURRENT_DATE, $1, 1)
+ ON CONFLICT (dt) DO UPDATE SET
+ realized_pnl = trading_daily_pnl.realized_pnl + EXCLUDED.realized_pnl,
+ trades_count = trading_daily_pnl.trades_count + 1,
+ updated_at = NOW()
+ """, realized)
+ sign = "🟢" if realized > 0 else "🔴"
+ msg = (f"{sign} 매도 {tag} #{order_id} (모의)\n"
+ f"{order['stock_name']}({order['stock_code']}) "
+ f"{order['qty']}주 × {order['price']:,}원\n"
+ f"실현손익: {realized:+,}원")
+ await conn.execute("""
+ UPDATE trading_orders
+ SET status='filled', confirmed_at=NOW(), filled_at=NOW()
+ WHERE id=$1
+ """, order_id)
+ await send_telegram(msg)
+ return {"status": "filled", "order_id": order_id}
+
@app.post("/trade/confirm/{order_id}")
async def trade_confirm_v2(order_id: int): # noqa: F811 (이전 정의 override)
- """매수/매도 confirm 통합 처리."""
+ """매수/매도 confirm 통합 처리 (수동 버튼)."""
async with pg_pool.acquire() as conn:
- order = await conn.fetchrow(
- "SELECT * FROM trading_orders WHERE id=$1 AND status='pending'", order_id)
- if not order:
- return {"error": "주문 없음 또는 이미 처리됨"}
- if order["expires_at"] and order["expires_at"] < datetime.now(timezone.utc):
- await conn.execute(
- "UPDATE trading_orders SET status='expired' WHERE id=$1", order_id)
- return {"error": "주문 만료"}
- if order["side"] == "buy":
- await conn.execute("""
- INSERT INTO user_portfolio (stock_code, stock_name, buy_price, qty, memo)
- VALUES ($1, $2, $3, $4, $5)
- """, order["stock_code"], order["stock_name"], order["price"],
- order["qty"], f"자동매매 #{order_id} (모의)")
- msg = (f"✅ 매수 체결 #{order_id}\n"
- f"{order['stock_name']}({order['stock_code']}) "
- f"{order['qty']}주 × {order['price']:,}원")
- else: # sell
- # 보유 종목 비활성화 + 실현손익 계산
- held = await conn.fetchrow("""
- SELECT id, buy_price, qty FROM user_portfolio
- WHERE stock_code=$1 AND active=true LIMIT 1
- """, order["stock_code"])
- realized = 0
- if held:
- realized = (order["price"] - held["buy_price"]) * order["qty"]
- await conn.execute(
- "UPDATE user_portfolio SET active=false WHERE id=$1", held["id"])
- # 일일 손익 누적
- await conn.execute("""
- INSERT INTO trading_daily_pnl (dt, realized_pnl, trades_count)
- VALUES (CURRENT_DATE, $1, 1)
- ON CONFLICT (dt) DO UPDATE SET
- realized_pnl = trading_daily_pnl.realized_pnl + EXCLUDED.realized_pnl,
- trades_count = trading_daily_pnl.trades_count + 1,
- updated_at = NOW()
- """, realized)
- sign = "🟢" if realized > 0 else "🔴"
- msg = (f"{sign} 매도 체결 #{order_id}\n"
- f"{order['stock_name']}({order['stock_code']}) "
- f"{order['qty']}주 × {order['price']:,}원\n"
- f"실현손익: {realized:+,}원")
- await conn.execute("""
- UPDATE trading_orders
- SET status='filled', confirmed_at=NOW(), filled_at=NOW()
- WHERE id=$1
- """, order_id)
- await send_telegram(msg)
- return {"status": "filled", "order_id": order_id}
+ return await _fill_order(conn, order_id, auto=False)
+
+@app.get("/trade/history")
+async def trade_history(days: int = Query(default=30, ge=1, le=365)):
+ """모의매매 이력 + 손익 — 체결내역·일별손익·보유 평가손익."""
+ async with pg_pool.acquire() as conn:
+ fills = await conn.fetch("""
+ SELECT stock_code, stock_name, side, qty, price, filled_at
+ FROM trading_orders
+ WHERE status='filled' AND filled_at >= CURRENT_DATE - ($1::int)
+ ORDER BY filled_at DESC
+ """, days)
+ daily = await conn.fetch("""
+ SELECT dt, realized_pnl, unrealized_pnl, trades_count, halted
+ FROM trading_daily_pnl WHERE dt >= CURRENT_DATE - ($1::int) ORDER BY dt DESC
+ """, days)
+ holds = await conn.fetch("""
+ SELECT p.stock_code, p.stock_name, p.buy_price, p.qty,
+ (SELECT price FROM stock_technical WHERE stock_code=p.stock_code) cur
+ FROM user_portfolio p WHERE active=true
+ """)
+ realized = sum((r["realized_pnl"] or 0) for r in daily)
+ holdings, unreal = [], 0
+ for h in holds:
+ cur = h["cur"] or h["buy_price"]
+ pl = int((cur - h["buy_price"]) * h["qty"]) if h["buy_price"] else 0
+ unreal += pl
+ holdings.append({"code": h["stock_code"], "name": h["stock_name"],
+ "buy_price": h["buy_price"], "qty": h["qty"], "cur_price": cur,
+ "pl": pl, "pl_pct": round((cur / h["buy_price"] - 1) * 100, 1) if h["buy_price"] else 0})
+ return {
+ "realized_pnl": realized, "unrealized_pnl": unreal, "total_pnl": realized + unreal,
+ "fill_count": len(fills),
+ "holdings": holdings,
+ "daily_pnl": [{"dt": str(r["dt"]), "realized": r["realized_pnl"],
+ "unrealized": r["unrealized_pnl"], "trades": r["trades_count"],
+ "halted": r["halted"]} for r in daily],
+ "fills": [{"code": f["stock_code"], "name": f["stock_name"], "side": f["side"],
+ "qty": f["qty"], "price": f["price"], "at": str(f["filled_at"])} for f in fills],
+ }
async def auto_trade_scan_job():
@@ -6298,6 +6341,13 @@ async def auto_trade_scan_job():
sig = await evaluate_buy_signal(conn, c)
if sig["ok"]:
await propose_buy_order(conn, c)
+ # 자동실행(모의): auto_execute면 오늘 제안된 pending 주문 즉시 체결
+ if TRADE_SETTINGS.get("auto_execute"):
+ pend = await conn.fetch(
+ "SELECT id FROM trading_orders WHERE status='pending' "
+ "AND proposed_at::date=CURRENT_DATE")
+ for p in pend:
+ await _fill_order(conn, p["id"], auto=True)
logger.info("auto_trade_scan.done")
except Exception as e:
logger.error("auto_trade_scan.err", error=str(e))