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