Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 024122d310 | |||
| c6f269e30a | |||
| 97447a551f | |||
| fc0d341d15 | |||
| 688ee3350d | |||
| f40da64a05 | |||
| 475950be36 | |||
| 27a56e65b7 | |||
| a9986fbc4c | |||
| ee14f5fbe4 | |||
| 3f45fbc36e | |||
| 87949f42b5 | |||
| 4eb23a41ca | |||
| 6db27fd634 | |||
| a3fe301308 | |||
| bcc9b76212 | |||
| 7172de3623 | |||
| 6582ffc02a | |||
| f8ff3a2c46 | |||
| ecc7deed08 | |||
| ee0787683b | |||
| f8d93de299 | |||
| 7db7f74713 | |||
| fbb56400af | |||
| 744e9d6a7a | |||
| 521d1c0b3d | |||
| d49e45b308 | |||
| 49dc382a39 | |||
| 15b9840204 | |||
| 534582972d | |||
| 890ed68a0f | |||
| 92efa934a2 | |||
| cc001fa263 | |||
| 606664404b | |||
| a7f9b27a55 | |||
| 34991eca2d | |||
| d833d386d0 | |||
| daec1a0e1b | |||
| a9b8f46187 | |||
| eb48b7eb07 | |||
| 187cdf99c0 | |||
| 21da79cb46 | |||
| 49637f1b02 | |||
| e15e4c6e20 | |||
| 6e666844f0 | |||
| 3f6c21c86e | |||
| 6fb43fb411 | |||
| 5d89c6ad02 | |||
| 1d90580854 | |||
| c25e3bee7a | |||
| 6e20a924db | |||
| 780ccee1fe | |||
| 129e2ec2d7 | |||
| 4cd1cab466 | |||
| be043a85e3 | |||
| 1c46d7b558 | |||
| 7f0c9b9a27 | |||
| 42d45e85fb | |||
| e7d1069222 | |||
| a274ef448a |
+53
-69
@@ -1,13 +1,17 @@
|
||||
name: Validators (Pull Requests Only)
|
||||
name: Validators (Pushes and Pull Requests)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
# Phase 3: Validator pipeline
|
||||
# Main branch push validation moved to merge-to-main.yml
|
||||
# This workflow runs validators only on PRs for feedback
|
||||
# Validator pipeline. Independent validation jobs run in parallel.
|
||||
|
||||
concurrency:
|
||||
group: quantengine-ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate-core:
|
||||
@@ -32,38 +36,15 @@ jobs:
|
||||
- name: Setup Python Environment
|
||||
run: |
|
||||
# 순수 Python 패키지만 설치 (numpy/pandas 제외 — ARMv7l 휠 없음)
|
||||
VENV_BASE=$HOME/python_venv
|
||||
REQ_HASH=$(md5sum tools/validate_specs.py 2>/dev/null | cut -d' ' -f1 || echo "default")
|
||||
VENV="$VENV_BASE/$REQ_HASH"
|
||||
|
||||
if [ ! -f "$VENV/bin/python" ]; then
|
||||
echo "=== venv 신규 생성: $REQ_HASH ==="
|
||||
mkdir -p "$VENV_BASE"
|
||||
/usr/bin/python3 -m venv "$VENV"
|
||||
|
||||
# venv 내 pip 확인 및 복구
|
||||
if [ ! -f "$VENV/bin/pip" ]; then
|
||||
echo "pip missing in venv, installing via get-pip.py..."
|
||||
curl -sS https://bootstrap.pypa.io/pip/3.8/get-pip.py -o get-pip.py
|
||||
"$VENV/bin/python" get-pip.py --quiet
|
||||
rm get-pip.py
|
||||
fi
|
||||
|
||||
"$VENV/bin/pip" install --upgrade pip --quiet
|
||||
"$VENV/bin/pip" install requests pyyaml openpyxl --quiet
|
||||
|
||||
# 오래된 venv 정리 (최근 2개만 유지)
|
||||
ls -dt "$VENV_BASE"/*/ 2>/dev/null | tail -n +3 | xargs rm -rf 2>/dev/null || true
|
||||
else
|
||||
echo "=== venv 캐시 히트: $("$VENV/bin/python" --version 2>&1) ==="
|
||||
"$VENV/bin/python" - <<'PY'
|
||||
import importlib
|
||||
for mod in ("requests", "yaml", "openpyxl"):
|
||||
importlib.import_module(mod)
|
||||
print("venv dependency import check: PASS")
|
||||
PY
|
||||
fi
|
||||
echo "$VENV/bin" >> $GITHUB_PATH
|
||||
PYTHON_DEPS="$HOME/python_deps/$(md5sum tools/validate_specs.py | cut -d' ' -f1)"
|
||||
mkdir -p "$PYTHON_DEPS"
|
||||
/usr/bin/python3 --version
|
||||
/usr/bin/python3 -m pip --version
|
||||
/usr/bin/python3 -m pip install --disable-pip-version-check --quiet \
|
||||
--target "$PYTHON_DEPS" requests pyyaml openpyxl pytest
|
||||
export PYTHONPATH="$PYTHON_DEPS:${PYTHONPATH:-}"
|
||||
echo "PYTHONPATH=$PYTHON_DEPS:${PYTHONPATH:-}" >> "$GITHUB_ENV"
|
||||
/usr/bin/python3 -c 'import requests, yaml, openpyxl, pytest; print("Python dependencies: PASS")'
|
||||
|
||||
- name: Install Node Dependencies
|
||||
run: |
|
||||
@@ -86,7 +67,7 @@ jobs:
|
||||
fi
|
||||
else
|
||||
echo "=== npm install (최초 or lock 변경) ==="
|
||||
npm install --quiet
|
||||
npm ci --quiet
|
||||
# 캐시 저장
|
||||
mkdir -p "$CACHE_DIR"
|
||||
cp -r node_modules "$CACHE_DIR/node_modules"
|
||||
@@ -120,6 +101,29 @@ jobs:
|
||||
- name: Validate Platform Transition WBS
|
||||
run: python3 tools/validate_platform_transition_wbs_v1.py
|
||||
|
||||
- name: Validate Schema Model Generation
|
||||
run: python3 tools/generate_schema_model_generation_evidence_v1.py && python3 tools/validate_schema_model_generation_v1.py
|
||||
|
||||
- name: Validate Market Time Series Schema
|
||||
run: python3 tools/validate_market_time_series_schema_v1.py
|
||||
|
||||
- name: Generate DONE WBS Verdicts
|
||||
run: |
|
||||
for task in QE-M0-01 QE-M0-02 QE-M0-03 QE-M0-04 QE-M0-05 QE-M0-06; do
|
||||
python3 tools/verify_wbs_task_v1.py --task "$task"
|
||||
done
|
||||
|
||||
- name: Validate Quant Engine WBS
|
||||
run: python3 tools/validate_quant_engine_wbs_v1.py
|
||||
|
||||
- name: Setup .NET SDK
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Run .NET Unit Tests
|
||||
run: dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Release --nologo
|
||||
|
||||
- name: Build Calibration Priority Backlog
|
||||
run: python3 tools/build_calibration_priority_v1.py
|
||||
|
||||
@@ -168,8 +172,9 @@ jobs:
|
||||
- name: Ensure Temp Directory and Mock Packet
|
||||
run: |
|
||||
mkdir -p Temp
|
||||
python3 -c 'import json; json.dump({"order_blueprint_json":{},"cash_recovery_plan_json":{},"per_ticker":[{"ticker":"DATA_MISSING","gate":"DATA_MISSING"}],"meta":{"formulas_run":[],"source_file":"GatherTradingData.json"}},open("Temp/computed_harness_v1.json","w"),ensure_ascii=False,indent=2)'
|
||||
if [ ! -f Temp/final_decision_packet_active.json ]; then
|
||||
echo '{"formula_id":"FINAL_DECISION_PACKET_V2","meta":{"generated_at":"2026-06-29T00:00:00Z"},"canonical_metrics":{},"portfolio_snapshot":{},"order_table":[]}' > Temp/final_decision_packet_active.json
|
||||
python3 -c 'import json; json.dump({"formula_id":"FINAL_DECISION_PACKET_V2","meta":{"generated_at":"2026-06-29T00:00:00Z"},"canonical_metrics":{"total_asset_krw":None},"portfolio_snapshot":{},"order_table":[],"pass_100":{"gate":"DATA_MISSING","score_0_100":None},"execution_readiness":{"gate":"DATA_MISSING","min_axis_score":None},"prediction":{"match_rate_pct":None}},open("Temp/final_decision_packet_active.json","w"),ensure_ascii=False,indent=2)'
|
||||
fi
|
||||
|
||||
- name: Validate Replay Live Separation
|
||||
@@ -197,7 +202,7 @@ jobs:
|
||||
run: python3 tools/validate_postgresql_history_contract_v1.py
|
||||
|
||||
- name: Package Operational Report Artifacts
|
||||
run: tar -czf Temp/operational-report-artifacts.tar.gz Temp/operational_report.json Temp/operational_report.md Temp/missing_data_inventory_v1.json Temp/report_section_completeness.json Temp/operational_alpha_calibration_v2.json Temp/validate_operational_alpha_calibration_v2.json Temp/operational_t20_outcome_ledger_v1.json Temp/live_data_activation_gate_v1.json Temp/replay_live_separation_v1.json Temp/validate_report_packet_sync_v1.json Temp/json_generator_outputs_v1.json Temp/proposal_evaluation_history.json Temp/performance_readiness_replay_bridge_v1.json Temp/postgresql_history_schema_v1.sql Temp/postgresql_history_schema_v1.json Temp/postgresql_history_contract_v1.json
|
||||
run: tar -czf Temp/operational-report-artifacts.tar.gz Temp/operational_report.json Temp/missing_data_inventory_v1.json Temp/report_section_completeness.json Temp/operational_alpha_calibration_v2.json Temp/validate_operational_alpha_calibration_v2.json Temp/operational_t20_outcome_ledger_v1.json Temp/live_data_activation_gate_v1.json Temp/replay_live_separation_v1.json Temp/validate_report_packet_sync_v1.json Temp/json_generator_outputs_v1.json Temp/proposal_evaluation_history.json Temp/performance_readiness_replay_bridge_v1.json Temp/postgresql_history_schema_v1.sql Temp/postgresql_history_schema_v1.json Temp/postgresql_history_contract_v1.json
|
||||
|
||||
- name: Upload Operational Report Artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -213,8 +218,6 @@ jobs:
|
||||
|
||||
validate-ui-and-storage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: validate-core
|
||||
if: github.event_name != 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
@@ -224,47 +227,28 @@ jobs:
|
||||
|
||||
- name: Setup Python Environment
|
||||
run: |
|
||||
VENV_BASE=$HOME/python_venv
|
||||
REQ_HASH=$(md5sum tools/validate_snapshot_admin_web_v1.py 2>/dev/null | cut -d' ' -f1 || echo "default")
|
||||
VENV="$VENV_BASE/$REQ_HASH"
|
||||
|
||||
if [ ! -f "$VENV/bin/python" ]; then
|
||||
echo "=== venv 신규 생성: $REQ_HASH ==="
|
||||
mkdir -p "$VENV_BASE"
|
||||
/usr/bin/python3 -m venv "$VENV"
|
||||
|
||||
if [ ! -f "$VENV/bin/pip" ]; then
|
||||
echo "pip missing in venv, installing via get-pip.py..."
|
||||
curl -sS https://bootstrap.pypa.io/pip/3.8/get-pip.py -o get-pip.py
|
||||
"$VENV/bin/python" get-pip.py --quiet
|
||||
rm get-pip.py
|
||||
fi
|
||||
|
||||
"$VENV/bin/pip" install --upgrade pip --quiet
|
||||
"$VENV/bin/pip" install requests pyyaml openpyxl --quiet
|
||||
else
|
||||
echo "=== venv 캐시 히트: $("$VENV/bin/python" --version 2>&1) ==="
|
||||
fi
|
||||
echo "$VENV/bin" >> $GITHUB_PATH
|
||||
PYTHON_DEPS="$HOME/python_deps/$(md5sum tools/validate_snapshot_admin_web_v1.py | cut -d' ' -f1)"
|
||||
mkdir -p "$PYTHON_DEPS"
|
||||
/usr/bin/python3 --version
|
||||
/usr/bin/python3 -m pip --version
|
||||
/usr/bin/python3 -m pip install --disable-pip-version-check --quiet \
|
||||
--target "$PYTHON_DEPS" requests pyyaml openpyxl pytest
|
||||
export PYTHONPATH="$PYTHON_DEPS:${PYTHONPATH:-}"
|
||||
echo "PYTHONPATH=$PYTHON_DEPS:${PYTHONPATH:-}" >> "$GITHUB_ENV"
|
||||
/usr/bin/python3 -c 'import requests, yaml, openpyxl, pytest; print("Python dependencies: PASS")'
|
||||
|
||||
- name: Validate Snapshot Admin Web UI
|
||||
if: needs.validate-core.result == 'success'
|
||||
run: python3 tools/validate_snapshot_admin_web_v1.py
|
||||
|
||||
- name: Validate Storage Backend Contracts
|
||||
if: needs.validate-core.result == 'success'
|
||||
run: python3 -m pytest tests/unit/test_storage_backend_v1.py tests/unit/test_validate_kis_api_credentials_v1.py tests/unit/test_qualitative_sell_strategy_store_v1.py tests/unit/test_kis_api_client_v1.py tests/unit/test_snapshot_admin_store_v1.py tests/unit/test_snapshot_admin_web_v1.py -q
|
||||
|
||||
- name: Notify PR Result
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
env:
|
||||
CORE_RESULT: ${{ needs.validate-core.result }}
|
||||
STAGE_RESULT: ${{ job.status }}
|
||||
run: |
|
||||
STATUS="$STAGE_RESULT"
|
||||
if [ "$CORE_RESULT" != "success" ]; then
|
||||
STATUS="failure"
|
||||
fi
|
||||
PR_NUM="${{ github.event.pull_request.number }}"
|
||||
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Prepare Release"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release:
|
||||
@@ -22,6 +25,7 @@ env:
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to Production
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
@@ -204,12 +208,8 @@ jobs:
|
||||
echo "ERROR: QuantEngine.Web.dll not found"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$DEPLOY_DIR/appsettings.Production.json" ]; then
|
||||
echo "ERROR: appsettings.Production.json not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ DLL verified"
|
||||
echo "✓ Config verified"
|
||||
echo "✓ Runtime configuration is managed outside the release artifact"
|
||||
|
||||
# 3. Update Symlink
|
||||
echo ""
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: KIS Data Collection Validation
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 0 * * 1-5"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Validate mock credentials
|
||||
env:
|
||||
KIS_APP_Key_TEST: ${{ vars.KIS_APP_KEY_TEST }}
|
||||
KIS_APP_Secret_TEST: ${{ vars.KIS_APP_SECRET_TEST }}
|
||||
KIS_APP_Key: ${{ vars.KIS_APP_KEY }}
|
||||
KIS_APP_Secret: ${{ vars.KIS_APP_SECRET }}
|
||||
run: python3 tools/validate_kis_api_credentials_v1.py --account mock --ticker 005930 --dry-run
|
||||
- name: Validate .NET PostgreSQL JSON cutover
|
||||
run: python3 tools/validate_dotnet_postgresql_json_cutover_v1.py
|
||||
@@ -1,6 +1,9 @@
|
||||
name: Prepare Release
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Validators (Pushes and Pull Requests)"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -14,6 +17,7 @@ env:
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: Build & Create Release
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Qualitative Sell Strategy Validation
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "15 0 * * 1-5"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
DEPS="$RUNNER_TEMP/quantengine_sell_deps"
|
||||
python3 -m pip install --disable-pip-version-check --quiet --target "$DEPS" pyyaml
|
||||
echo "PYTHONPATH=$DEPS:${PYTHONPATH:-}" >> "$GITHUB_ENV"
|
||||
- name: Validate mock credentials
|
||||
env:
|
||||
KIS_APP_Key_TEST: ${{ vars.KIS_APP_KEY_TEST }}
|
||||
KIS_APP_Secret_TEST: ${{ vars.KIS_APP_SECRET_TEST }}
|
||||
run: python3 tools/validate_kis_api_credentials_v1.py --account mock --ticker 005930 --dry-run
|
||||
- name: Validate qualitative sell pipeline
|
||||
run: python3 tools/validate_qualitative_sell_strategy_pipeline_v1.py
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Snapshot Admin Validation
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "src/quant_engine/snapshot_admin_*.py"
|
||||
- "tools/validate_snapshot_admin_*.py"
|
||||
- "tests/unit/test_snapshot_admin_*.py"
|
||||
- ".gitea/workflows/snapshot_admin.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
PYTHON_DEPS="$RUNNER_TEMP/quantengine_snapshot_admin_deps"
|
||||
python3 -m pip install --disable-pip-version-check --quiet --target "$PYTHON_DEPS" pyyaml pytest
|
||||
echo "PYTHONPATH=$PYTHON_DEPS:${PYTHONPATH:-}" >> "$GITHUB_ENV"
|
||||
- name: Validate snapshot admin workflow
|
||||
run: python3 tools/validate_snapshot_admin_workflow_v1.py
|
||||
- name: Run snapshot admin tests
|
||||
run: python3 -m pytest tests/unit/test_snapshot_admin_store_v1.py tests/unit/test_snapshot_admin_web_v1.py -q
|
||||
@@ -1,5 +1,11 @@
|
||||
# 은퇴자산포트폴리오 투자 에이전트 운영 지침
|
||||
|
||||
## QuantEngine 운영 설정 권위
|
||||
- `ConnectionStrings__DefaultConnection`은 운영 설정에서 관리한다.
|
||||
- 저장소 코드, DbUp migration, CI artifact는 운영 계정 비밀번호를 생성하거나 덮어쓰지 않는다. 단, 명시된 운영 설정 복원 작업은 예외로 한다.
|
||||
- 배포/검증 하네스는 설정값을 읽기만 하며, 값 자체를 로그·증빙·커밋에 기록하지 않는다.
|
||||
- 설정 변경은 애플리케이션 배포와 분리된 운영 설정 변경으로 취급한다. 설정 복원 시에는 Git 이력의 마지막 권위값만 사용한다.
|
||||
|
||||
## 0. 최우선 원칙
|
||||
- 이 파일은 운영 인덱스다. 상세 규칙은 `governance/rules/*.yaml`와 `spec/*.yaml`를 우선한다.
|
||||
- 가격, 수량, TP/SL, 점수는 오직 `spec/13_formula_registry.yaml`와 하네스 산출값만 사용한다.
|
||||
|
||||
@@ -688,3 +688,6 @@ See `docs/GITEA_ACTIONS_API_GUIDE.md` for complete API reference.
|
||||
- **Newtonsoft.Json**: Known high-severity vulnerability (GHSA-5crp-9r3c-p9vr); update or replace when feasible
|
||||
- **Release Authority**: Python gates (`full-gate`, `prepare-upload-zip`) remain authority; .NET Admin fully operational as of 2026-07-11
|
||||
- **Testing Requirement**: All code changes must pass local testing with SSH tunnel to remote DB before deployment (see "Local Development & Testing" above)
|
||||
- **DBML Schema Sync (2026-07-12)**: DbUp 마이그레이션(`src/dotnet/QuantEngine.Infrastructure/Migrations/V*.sql`)으로 관리되는 모든 테이블은 **반드시 `docs/db/quantengine.dbml`에도 동기화**되어야 하며, 개발 시 스키마 참조는 이 DBML 파일을 기준으로 한다. 새 마이그레이션 추가 시 같은 커밋에서 DBML 갱신 필수.
|
||||
- **Diagrams**: 상태전이/플로우차트/시퀀스 다이어그램은 Mermaid로 `docs/diagrams/`에 작성해 코딩 참조로 활용 (수집 파이프라인: `docs/diagrams/collection-pipeline.md`)
|
||||
- **WBS Evidence Gate (2026-07-12)**: 퀀트 엔진 로드맵/WBS는 `spec/60_quant_engine_wbs.yaml`(기계 판정)로 관리. 작업 완료는 `npm run verify:task -- <TASK_ID>` 게이트 PASS로만 인정 (BE=PG쿼리/로그/JSON, FE=Playwright+스크린샷). 전체 게이트: `npm run verify:wbs`
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Report Guide (보고서 지침)
|
||||
|
||||
본 문서는 은퇴자산 포트폴리오 투자 에이전트의 보고 및 작업 완료 기준을 정의합니다.
|
||||
|
||||
## 기본 완료 조건 (Default Completion Harness)
|
||||
모든 작업은 아래의 4가지 요소가 모두 충족되어 검증을 통과해야 완료로 판정합니다.
|
||||
|
||||
1. **YAML 계약/공식**: 계약, 공식 및 거버넌스 파일(`yaml`)의 원본 권위가 변경 사항에 맞게 최신화되어야 합니다.
|
||||
2. **코드 구현**: `code` 구현이 `src/` 또는 `tools/`에 명확히 반영되어야 합니다.
|
||||
3. **데이터 실체**: 수집 및 계산 결과가 담긴 데이터 실체(`data artifact` 또는 `data/artifact`)가 디렉토리에 정상적으로 생성되고 확인되어야 합니다.
|
||||
4. **검증 증빙**: 재현 가능한 테스트 실행 및 검증 명령의 결과 파일 또는 터미널 출력이 `validation evidence`(`검증 증빙`)로 기록되어야 합니다.
|
||||
|
||||
이러한 완료 프로세스는 `completion harness`를 통해 엄격하게 통제됩니다.
|
||||
@@ -2271,3 +2271,17 @@ python tools/validate_snapshot_admin_web_v1.py
|
||||
> 이 문서는 `docs/ROADMAP_WBS.md` 에 저장됩니다.
|
||||
> 스프린트 완료마다 **완성도 KPI 섹션**을 업데이트하세요.
|
||||
> 모든 WBS 항목의 구현 시 반드시 **하네스 성공 기준**을 먼저 충족 후 다음 단계로 진행합니다.
|
||||
|
||||
---
|
||||
|
||||
## 차세대 퀀트 엔진 로드맵/WBS 포인터 (2026-07-12)
|
||||
|
||||
이후의 퀀트 엔진 진화 로드맵(M0–M5: 실증 하네스 → 수집 배선 → 시계열 저장소 →
|
||||
실데이터 팩터 → 백테스팅 → 포트폴리오/레짐)과 상세 WBS는 **기계 판정 YAML**로 관리한다:
|
||||
|
||||
- **스펙(단일 진실 원천)**: `spec/60_quant_engine_wbs.yaml` (formula_id: `QUANT_ENGINE_WBS_V1`)
|
||||
- **단일 작업 검증**: `python tools/verify_wbs_task_v1.py --task <TASK_ID>` → `Temp/evidence/<TASK_ID>/verdict.json`
|
||||
- **전체 WBS 게이트**: `python tools/validate_quant_engine_wbs_v1.py` → `Temp/quant_engine_wbs_v1.json`
|
||||
|
||||
완료 판정 원칙: 작업은 게이트 실행(PASS)으로만 `DONE` 이 될 수 있다.
|
||||
BE = PostgreSQL 쿼리 + Serilog 로그 패턴 + JSON 아티팩트, FE = Playwright(DOM assert + API 기대값 대조 + 스크린샷).
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
// =============================================================================
|
||||
// QuantEngine Database Schema (DBML)
|
||||
// DbUp 마이그레이션(V1~V5)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신
|
||||
// (CLAUDE.md 규칙: schema 변경 → DBML + 문서 동기화)
|
||||
//
|
||||
// 참고: Hangfire 스키마는 Hangfire.PostgreSql 라이브러리가 자동 생성
|
||||
// (DbUp 마이그레이션으로 관리하지 않음, 여기서도 제외)
|
||||
// =============================================================================
|
||||
|
||||
Project quantengine {
|
||||
database_type: 'PostgreSQL'
|
||||
Note: '''
|
||||
QuantEngine v0.1 데이터베이스 스키마.
|
||||
세 개 스키마로 구성:
|
||||
- quantengine: 핵심 KIS API 토큰, 사용자 계정, 수집 파이프라인 데이터
|
||||
- engine_history: 팩터 계산 이력, 시장 데이터 이력, 의사결정 이력
|
||||
- (생략) hangfire: Hangfire 백그라운드 잡 관리 (auto-created)
|
||||
'''
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Schema: quantengine (V1 + V2)
|
||||
// =============================================================================
|
||||
|
||||
TableGroup "quantengine" {
|
||||
kis_tokens
|
||||
workspace_account
|
||||
workspace_session
|
||||
collection_runs
|
||||
collection_snapshots
|
||||
collection_source_errors
|
||||
settings
|
||||
account_snapshot
|
||||
workspace_meta
|
||||
workspace_change_log
|
||||
workspace_approval_v2
|
||||
workspace_lock
|
||||
kis_collection_runs
|
||||
kis_collection_snapshots
|
||||
kis_collection_errors
|
||||
}
|
||||
|
||||
Table quantengine.kis_tokens {
|
||||
account TEXT [pk, note: "KIS 계정 모드 (real/mock)"]
|
||||
access_token TEXT [not null, note: "KIS 토큰"]
|
||||
expires_at TEXT [not null, note: "만료 시각 (ISO 8601)"]
|
||||
updated_at TEXT [not null, note: "마지막 갱신 시각 (ISO 8601)"]
|
||||
|
||||
Note: "KIS Open API 인증 토큰 캐시"
|
||||
}
|
||||
|
||||
Table quantengine.workspace_account {
|
||||
ordinal INT [not null, note: "순서 인덱스"]
|
||||
username TEXT [pk, note: "로그인 ID"]
|
||||
password_hash TEXT [not null, note: "BCrypt 또는 SHA-256 해시 (자동 마이그레이션 가능)"]
|
||||
role TEXT [not null, default: "'Admin'", note: "역할 (Admin)"]
|
||||
is_active TEXT [not null, default: "'true'", note: "활성 상태 (true/false)"]
|
||||
created_at TEXT [not null, note: "생성 시각 (ISO 8601)"]
|
||||
updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"]
|
||||
|
||||
indexes {
|
||||
(is_active, username) [name: "idx_workspace_account_active"]
|
||||
}
|
||||
|
||||
Note: "Admin UI 사용자 계정"
|
||||
}
|
||||
|
||||
Table quantengine.workspace_session {
|
||||
session_token_hash TEXT [pk, note: "세션 토큰 해시"]
|
||||
username TEXT [not null, note: "사용자명"]
|
||||
role TEXT [not null, default: "'Admin'", note: "역할"]
|
||||
created_at TEXT [not null, note: "세션 생성 시각 (ISO 8601)"]
|
||||
expires_at TEXT [not null, note: "만료 시각 (ISO 8601)"]
|
||||
revoked_at TEXT [note: "취소 시각 (ISO 8601), NULL이면 활성"]
|
||||
|
||||
indexes {
|
||||
(username, expires_at) [name: "idx_workspace_session_username"]
|
||||
}
|
||||
|
||||
Note: "세션 관리 (쿠키 기반 인증)"
|
||||
}
|
||||
|
||||
Table quantengine.collection_runs {
|
||||
run_id TEXT [pk, note: "수집 실행 ID (예: api-20260712-120000)"]
|
||||
collector_name TEXT [not null, note: "수집기 이름"]
|
||||
started_at TEXT [not null, note: "시작 시각 (ISO 8601)"]
|
||||
finished_at TEXT [note: "종료 시각 (ISO 8601)"]
|
||||
status TEXT [not null, note: "상태 (RUNNING/COMPLETED/FAILED)"]
|
||||
input_source TEXT [note: "입력 소스 경로"]
|
||||
output_json_path TEXT [note: "출력 JSON 파일 경로"]
|
||||
output_db_path TEXT [note: "출력 DB 경로"]
|
||||
notes TEXT [note: "메모"]
|
||||
created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"]
|
||||
|
||||
Note: "데이터 수집 실행 기록 (레거시, V2의 kis_collection_runs 참조)"
|
||||
}
|
||||
|
||||
Table quantengine.collection_snapshots {
|
||||
run_id TEXT [not null, note: "수집 실행 ID"]
|
||||
dataset_name TEXT [not null, note: "데이터셋명"]
|
||||
ticker TEXT [not null, note: "종목코드 (예: 005930)"]
|
||||
name TEXT [note: "종목명"]
|
||||
sector TEXT [note: "업종"]
|
||||
as_of_date TEXT [note: "기준 일자"]
|
||||
source_priority TEXT [note: "소스 우선순위"]
|
||||
source_status TEXT [note: "소스 상태"]
|
||||
payload_json TEXT [not null, note: "정규화된 데이터 (JSON)"]
|
||||
provenance_json TEXT [not null, note: "출처 정보 (JSON)"]
|
||||
created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"]
|
||||
|
||||
indexes {
|
||||
(run_id, dataset_name, ticker) [pk]
|
||||
(ticker, created_at) [name: "idx_collection_snapshots_ticker_time"]
|
||||
}
|
||||
|
||||
Note: "수집 스냅샷 (레거시, V2의 kis_collection_snapshots 참조)"
|
||||
}
|
||||
|
||||
Table quantengine.collection_source_errors {
|
||||
run_id TEXT [not null, note: "수집 실행 ID"]
|
||||
ticker TEXT [note: "종목코드"]
|
||||
source_name TEXT [not null, note: "소스명"]
|
||||
error_kind TEXT [not null, note: "에러 타입"]
|
||||
error_message TEXT [not null, note: "에러 메시지"]
|
||||
payload_json TEXT [note: "에러 상세 (JSON)"]
|
||||
created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"]
|
||||
|
||||
indexes {
|
||||
(run_id, source_name) [name: "idx_collection_source_errors_run"]
|
||||
}
|
||||
|
||||
Note: "수집 중 발생한 에러 기록 (레거시)"
|
||||
}
|
||||
|
||||
Table quantengine.settings {
|
||||
ordinal INT [not null, note: "순서 인덱스"]
|
||||
key TEXT [pk, note: "설정 키"]
|
||||
value_json TEXT [not null, note: "값 (JSON)"]
|
||||
note TEXT [not null, default: "''", note: "설명"]
|
||||
updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"]
|
||||
|
||||
Note: "애플리케이션 설정 저장소"
|
||||
}
|
||||
|
||||
Table quantengine.account_snapshot {
|
||||
ordinal INT [not null, note: "순서 인덱스"]
|
||||
row_json TEXT [not null, note: "계정 데이터 (JSON)"]
|
||||
captured_at TEXT [not null, default: "''", note: "캡처 시각 (ISO 8601)"]
|
||||
account TEXT [not null, default: "''", note: "계정"]
|
||||
account_type TEXT [not null, default: "''", note: "계정 타입"]
|
||||
ticker TEXT [not null, default: "''", note: "종목코드"]
|
||||
name TEXT [not null, default: "''", note: "이름"]
|
||||
parse_status TEXT [not null, default: "''", note: "파싱 상태"]
|
||||
user_confirmed TEXT [not null, default: "''", note: "사용자 확인 여부"]
|
||||
updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"]
|
||||
|
||||
indexes {
|
||||
(captured_at) [name: "idx_account_snapshot_captured_at"]
|
||||
(ticker) [name: "idx_account_snapshot_ticker"]
|
||||
}
|
||||
|
||||
Note: "계정 스냅샷 저장소"
|
||||
}
|
||||
|
||||
Table quantengine.workspace_meta {
|
||||
key TEXT [pk, note: "메타 키"]
|
||||
value_json TEXT [not null, note: "값 (JSON)"]
|
||||
|
||||
Note: "워크스페이스 메타데이터"
|
||||
}
|
||||
|
||||
Table quantengine.workspace_change_log {
|
||||
id SERIAL [pk, note: "자동 증가 ID"]
|
||||
domain TEXT [not null, note: "도메인"]
|
||||
action TEXT [not null, note: "액션 (create/update/delete)"]
|
||||
target_ref TEXT [not null, default: "''", note: "대상 참조"]
|
||||
actor TEXT [not null, default: "'system'", note: "액터 (사용자/시스템)"]
|
||||
note TEXT [not null, default: "''", note: "메모"]
|
||||
before_json TEXT [not null, default: "'null'", note: "변경 전 값 (JSON)"]
|
||||
after_json TEXT [not null, default: "'null'", note: "변경 후 값 (JSON)"]
|
||||
created_at TEXT [not null, note: "기록 시각 (ISO 8601)"]
|
||||
|
||||
Note: "변경 로그"
|
||||
}
|
||||
|
||||
Table quantengine.workspace_approval_v2 {
|
||||
domain TEXT [not null, note: "도메인"]
|
||||
target_ref TEXT [not null, default: "'*'", note: "대상 참조"]
|
||||
status TEXT [not null, note: "승인 상태"]
|
||||
approved_by TEXT [not null, default: "''", note: "승인자"]
|
||||
approved_at TEXT [not null, default: "''", note: "승인 시각 (ISO 8601)"]
|
||||
note TEXT [not null, default: "''", note: "메모"]
|
||||
updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"]
|
||||
|
||||
indexes {
|
||||
(domain, target_ref) [pk]
|
||||
}
|
||||
|
||||
Note: "승인 워크플로우"
|
||||
}
|
||||
|
||||
Table quantengine.workspace_lock {
|
||||
domain TEXT [not null, note: "도메인"]
|
||||
target_ref TEXT [not null, default: "''", note: "대상 참조"]
|
||||
locked_by TEXT [not null, default: "''", note: "잠금 사용자"]
|
||||
reason TEXT [not null, default: "''", note: "잠금 사유"]
|
||||
locked_at TEXT [not null, note: "잠금 시각 (ISO 8601)"]
|
||||
|
||||
indexes {
|
||||
(domain, target_ref) [pk]
|
||||
}
|
||||
|
||||
Note: "동시성 제어용 잠금"
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// V2: KIS 수집 파이프라인 (kis_collection_*)
|
||||
// =============================================================================
|
||||
|
||||
Table quantengine.kis_collection_runs {
|
||||
run_id TEXT [pk, note: "수집 실행 ID"]
|
||||
status TEXT [not null, note: "상태: RUNNING / COMPLETED / COMPLETED_WITH_ERRORS / FAILED"]
|
||||
started_at TEXT [not null, note: "시작 시각 (ISO 8601 KST)"]
|
||||
finished_at TEXT [note: "종료 시각 (ISO 8601 KST)"]
|
||||
total_snapshots INTEGER [note: "성공한 스냅샷 수"]
|
||||
total_errors INTEGER [note: "발생한 에러 수"]
|
||||
updated_at TEXT [not null, note: "마지막 갱신 시각 (ISO 8601)"]
|
||||
|
||||
indexes {
|
||||
(started_at) [name: "idx_kis_runs_started_at"]
|
||||
}
|
||||
|
||||
Note: "KIS API 수집 실행 기록"
|
||||
}
|
||||
|
||||
Table quantengine.kis_collection_snapshots {
|
||||
run_id TEXT [not null, note: "수집 실행 ID"]
|
||||
dataset_name TEXT [note: "데이터셋명 (예: data_feed)"]
|
||||
ticker TEXT [not null, note: "종목코드 (예: 005930)"]
|
||||
source_name TEXT [not null, note: "데이터 소스 (kis_open_api 등)"]
|
||||
payload_json TEXT [not null, note: "정규화된 수집 데이터 (JSON)"]
|
||||
captured_at TEXT [not null, note: "캡처 시각 (ISO 8601 KST)"]
|
||||
created_at TEXT [not null, note: "DB 기록 시각 (ISO 8601)"]
|
||||
|
||||
indexes {
|
||||
(run_id, ticker, source_name) [pk]
|
||||
(ticker) [name: "idx_kis_snapshots_ticker"]
|
||||
(captured_at) [name: "idx_kis_snapshots_captured_at"]
|
||||
}
|
||||
|
||||
Note: "KIS API 수집 스냅샷 (시계열 데이터)"
|
||||
}
|
||||
|
||||
Table quantengine.kis_collection_errors {
|
||||
id SERIAL [pk, note: "자동 증가 ID"]
|
||||
run_id TEXT [not null, note: "수집 실행 ID"]
|
||||
source_name TEXT [not null, note: "데이터 소스"]
|
||||
error_kind TEXT [not null, note: "에러 타입 (예: HttpRequestException)"]
|
||||
error_message TEXT [note: "에러 메시지"]
|
||||
ticker TEXT [note: "종목코드 (해당하면)"]
|
||||
created_at TEXT [not null, note: "DB 기록 시각 (ISO 8601)"]
|
||||
|
||||
indexes {
|
||||
(run_id) [name: "idx_kis_errors_run_id"]
|
||||
}
|
||||
|
||||
Note: "KIS API 수집 중 발생한 에러"
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Schema: engine_history (V3)
|
||||
// =============================================================================
|
||||
|
||||
TableGroup "engine_history" {
|
||||
market_raw_history
|
||||
factor_version_history
|
||||
factor_output_history
|
||||
decision_result_history
|
||||
market_vs_engine_gap_history
|
||||
source_observation
|
||||
factor_definition
|
||||
factor_observation
|
||||
decision_event
|
||||
decision_factor_evidence
|
||||
outcome_evaluation
|
||||
}
|
||||
|
||||
Table engine_history.market_raw_history {
|
||||
id BIGSERIAL [pk, note: "자동 증가 ID"]
|
||||
source_id TEXT [not null, note: "소스 ID"]
|
||||
observed_at TEXT [not null, note: "관측 시각 (ISO 8601)"]
|
||||
source_name TEXT [not null, note: "소스명 (kis_open_api 등)"]
|
||||
instrument_id TEXT [not null, note: "상품 ID (종목코드 등)"]
|
||||
field_name TEXT [not null, note: "필드명 (현재가, 종가 등)"]
|
||||
field_value TEXT [not null, note: "필드값 (문자열)"]
|
||||
unit TEXT [not null, note: "단위 (원, % 등)"]
|
||||
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
|
||||
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
|
||||
|
||||
indexes {
|
||||
(created_at) [name: "idx_market_raw_history_created_at"]
|
||||
}
|
||||
|
||||
Note: "시장 데이터 원본 이력 (정규화 전)"
|
||||
}
|
||||
|
||||
Table engine_history.factor_version_history {
|
||||
id BIGSERIAL [pk, note: "자동 증가 ID"]
|
||||
factor_id TEXT [not null, note: "팩터 ID (예: momentum_ss001)"]
|
||||
factor_version TEXT [not null, note: "팩터 버전 (예: v1.0.0)"]
|
||||
effective_from TEXT [not null, note: "유효 시작 일자 (YYYYMMDD)"]
|
||||
effective_to TEXT [not null, note: "유효 종료 일자 (YYYYMMDD)"]
|
||||
formula_id TEXT [not null, note: "계산식 ID"]
|
||||
source_version TEXT [not null, note: "소스 버전"]
|
||||
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
|
||||
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
|
||||
|
||||
indexes {
|
||||
(created_at) [name: "idx_factor_version_history_created_at"]
|
||||
}
|
||||
|
||||
Note: "팩터 버전 관리 이력"
|
||||
}
|
||||
|
||||
Table engine_history.factor_output_history {
|
||||
id BIGSERIAL [pk, note: "자동 증가 ID"]
|
||||
factor_output_id TEXT [not null, note: "팩터 출력 ID"]
|
||||
observed_at TEXT [not null, note: "관측 일자 (YYYYMMDD)"]
|
||||
factor_id TEXT [not null, note: "팩터 ID"]
|
||||
factor_version TEXT [not null, note: "팩터 버전"]
|
||||
output_value TEXT [not null, note: "출력값 (문자열)"]
|
||||
output_gate TEXT [not null, note: "게이트 (PASS/FAIL/WARN)"]
|
||||
source_version TEXT [not null, note: "소스 버전"]
|
||||
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
|
||||
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
|
||||
|
||||
indexes {
|
||||
(created_at) [name: "idx_factor_output_history_created_at"]
|
||||
}
|
||||
|
||||
Note: "팩터 계산 결과 이력"
|
||||
}
|
||||
|
||||
Table engine_history.decision_result_history {
|
||||
id BIGSERIAL [pk, note: "자동 증가 ID"]
|
||||
decision_id TEXT [not null, note: "의사결정 ID"]
|
||||
decided_at TEXT [not null, note: "의사결정 일자 (YYYYMMDD)"]
|
||||
instrument_id TEXT [not null, note: "상품 ID (종목코드 등)"]
|
||||
action TEXT [not null, note: "액션 (BUY/SELL/HOLD)"]
|
||||
gate TEXT [not null, note: "게이트 (PASS/FAIL)"]
|
||||
score TEXT [not null, note: "스코어 (문자열)"]
|
||||
source_version TEXT [not null, note: "소스 버전"]
|
||||
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
|
||||
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
|
||||
|
||||
indexes {
|
||||
(created_at) [name: "idx_decision_result_history_created_at"]
|
||||
}
|
||||
|
||||
Note: "의사결정 결과 이력"
|
||||
}
|
||||
|
||||
Table engine_history.market_vs_engine_gap_history {
|
||||
id BIGSERIAL [pk, note: "자동 증가 ID"]
|
||||
gap_id TEXT [not null, note: "갭 ID"]
|
||||
observed_at TEXT [not null, note: "관측 일자 (YYYYMMDD)"]
|
||||
instrument_id TEXT [not null, note: "상품 ID"]
|
||||
metric_name TEXT [not null, note: "지표명"]
|
||||
market_value TEXT [not null, note: "시장값"]
|
||||
engine_value TEXT [not null, note: "엔진값"]
|
||||
gap_value TEXT [not null, note: "갭값 (절대값)"]
|
||||
gap_pct TEXT [not null, note: "갭 백분율 (%)"]
|
||||
source_version TEXT [not null, note: "소스 버전"]
|
||||
provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"]
|
||||
created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"]
|
||||
|
||||
indexes {
|
||||
(created_at) [name: "idx_market_vs_engine_gap_history_created_at"]
|
||||
}
|
||||
|
||||
Note: "시장 데이터 vs 엔진 계산 갭 분석 이력"
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Schema: engine_history (V5 normalized learning history)
|
||||
// =============================================================================
|
||||
|
||||
Table quantengine.price_history_daily {
|
||||
ticker TEXT [not null]
|
||||
trade_date DATE [not null]
|
||||
open NUMERIC [not null]
|
||||
high NUMERIC [not null]
|
||||
low NUMERIC [not null]
|
||||
close NUMERIC [not null]
|
||||
volume BIGINT [not null]
|
||||
source TEXT [not null]
|
||||
collected_at TIMESTAMPTZ [not null, default: "NOW()"]
|
||||
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||
|
||||
indexes {
|
||||
(ticker, trade_date) [pk]
|
||||
}
|
||||
}
|
||||
|
||||
Table quantengine.macro_history_daily {
|
||||
symbol TEXT [not null]
|
||||
trade_date DATE [not null]
|
||||
value NUMERIC [not null]
|
||||
source TEXT [not null]
|
||||
collected_at TIMESTAMPTZ [not null, default: "NOW()"]
|
||||
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||
|
||||
indexes {
|
||||
(symbol, trade_date) [pk]
|
||||
}
|
||||
}
|
||||
|
||||
Table engine_history.source_observation {
|
||||
observation_id UUID [pk]
|
||||
observed_at TIMESTAMPTZ [not null]
|
||||
instrument_id TEXT [not null]
|
||||
source_name TEXT [not null]
|
||||
source_version TEXT [not null]
|
||||
payload JSONB [not null]
|
||||
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||
created_at TIMESTAMPTZ [not null, default: "NOW()"]
|
||||
}
|
||||
|
||||
Table engine_history.factor_definition {
|
||||
factor_id TEXT [not null]
|
||||
factor_version TEXT [not null]
|
||||
formula_id TEXT [not null]
|
||||
effective_from TIMESTAMPTZ [not null]
|
||||
effective_to TIMESTAMPTZ
|
||||
definition JSONB [not null, default: "'{}'::jsonb"]
|
||||
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||
|
||||
indexes {
|
||||
(factor_id, factor_version) [pk]
|
||||
}
|
||||
}
|
||||
|
||||
Table engine_history.factor_observation {
|
||||
factor_observation_id UUID [pk]
|
||||
observation_id UUID [not null]
|
||||
factor_id TEXT [not null]
|
||||
factor_version TEXT [not null]
|
||||
observed_at TIMESTAMPTZ [not null]
|
||||
numeric_value NUMERIC
|
||||
text_value TEXT
|
||||
gate TEXT [not null]
|
||||
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||
}
|
||||
|
||||
Table engine_history.decision_event {
|
||||
decision_id UUID [pk]
|
||||
decision_key TEXT [not null, unique]
|
||||
decided_at TIMESTAMPTZ [not null]
|
||||
instrument_id TEXT [not null]
|
||||
action TEXT [not null]
|
||||
gate TEXT [not null]
|
||||
score NUMERIC
|
||||
source_version TEXT [not null]
|
||||
trace JSONB [not null, default: "'{}'::jsonb"]
|
||||
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||
created_at TIMESTAMPTZ [not null, default: "NOW()"]
|
||||
}
|
||||
|
||||
Table engine_history.decision_factor_evidence {
|
||||
decision_id UUID [not null]
|
||||
factor_observation_id UUID [not null]
|
||||
role TEXT [not null]
|
||||
|
||||
indexes {
|
||||
(decision_id, factor_observation_id) [pk]
|
||||
}
|
||||
}
|
||||
|
||||
Table engine_history.outcome_evaluation {
|
||||
evaluation_id UUID [pk]
|
||||
decision_id UUID [not null]
|
||||
horizon_days INT [not null]
|
||||
evaluated_at TIMESTAMPTZ [not null]
|
||||
realized_return NUMERIC
|
||||
benchmark_return NUMERIC
|
||||
excess_return NUMERIC
|
||||
outcome_class TEXT [not null]
|
||||
evaluation_gate TEXT [not null]
|
||||
provenance JSONB [not null, default: "'{}'::jsonb"]
|
||||
|
||||
indexes {
|
||||
(decision_id, horizon_days) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Relationships (Logical, not enforced as FKs in DDL)
|
||||
// =============================================================================
|
||||
|
||||
Ref: quantengine.kis_collection_snapshots.run_id > quantengine.kis_collection_runs.run_id {
|
||||
// logical relationship: snapshots belong to a run
|
||||
}
|
||||
|
||||
Ref: quantengine.kis_collection_errors.run_id > quantengine.kis_collection_runs.run_id {
|
||||
// logical relationship: errors belong to a run
|
||||
}
|
||||
|
||||
Ref: quantengine.workspace_session.username > quantengine.workspace_account.username {
|
||||
// logical relationship: session belongs to a user
|
||||
}
|
||||
|
||||
Ref: engine_history.factor_observation.observation_id > engine_history.source_observation.observation_id
|
||||
Ref: engine_history.factor_observation.(factor_id, factor_version) > engine_history.factor_definition.(factor_id, factor_version)
|
||||
Ref: engine_history.decision_factor_evidence.decision_id > engine_history.decision_event.decision_id
|
||||
Ref: engine_history.decision_factor_evidence.factor_observation_id > engine_history.factor_observation.factor_observation_id
|
||||
Ref: engine_history.outcome_evaluation.decision_id > engine_history.decision_event.decision_id
|
||||
@@ -0,0 +1,167 @@
|
||||
# QuantEngine 수집 파이프라인 (KIS API)
|
||||
|
||||
## 1. 수집 실행 상태 전이도 (State Diagram)
|
||||
|
||||
KIS 데이터 수집 실행(kis_collection_runs)의 상태 흐름. 상태값은 KisDataCollectionOrchestrator 에서 정의:
|
||||
- `RUNNING`: 수집 진행 중
|
||||
- `COMPLETED`: 모든 스냅샷 수집 완료 (에러 없음, `total_errors == 0`)
|
||||
- `COMPLETED_WITH_ERRORS`: 부분 수집 완료 (에러 발생, `total_errors > 0`이지만 일부 성공)
|
||||
- `FAILED`: 전체 실패 (예외 발생, 데이터 미적재)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> RUNNING: 수집 시작<br/>(RunCollectionAsync)
|
||||
RUNNING --> COMPLETED: 완료 & error_count==0
|
||||
RUNNING --> COMPLETED_WITH_ERRORS: 완료 & error_count>0
|
||||
RUNNING --> FAILED: 예외 발생
|
||||
COMPLETED --> [*]
|
||||
COMPLETED_WITH_ERRORS --> [*]
|
||||
FAILED --> [*]
|
||||
```
|
||||
|
||||
**상태 전이 조건** (KisDataCollectionOrchestrator.cs 라인 104-105):
|
||||
- `error_count == 0` → `COMPLETED`
|
||||
- `error_count > 0` → `COMPLETED_WITH_ERRORS`
|
||||
- 예외(Exception) → `FAILED`
|
||||
|
||||
**성공 기준** (CLAUDE.md "Collection Run Success Criteria"):
|
||||
- Success: `status == "COMPLETED"` (NOT failed)
|
||||
- Partial Success: `status == "COMPLETED"` + `total_snapshots > 0` + `total_errors > 0`
|
||||
- Failure: `status == "FAILED"` OR `total_snapshots == 0`
|
||||
|
||||
---
|
||||
|
||||
## 2. 수집 파이프라인 흐름도 (Flowchart)
|
||||
|
||||
KIS API 데이터 수집의 전체 흐름. 두 개의 트리거:
|
||||
1. **Hangfire 정기 작업**: 매일 09:00 에 자동 실행
|
||||
2. **API 수동 트리거**: POST /api/collection/run (쿠키 기반 인증)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Hangfire daily-collection<br/>(09:00 KST)"]
|
||||
B["POST /api/collection/run<br/>(Cookie Auth)"]
|
||||
|
||||
A --> C["IServiceScopeFactory.CreateScope<br/>(resolve ICollectionOrchestrator)"]
|
||||
B --> C
|
||||
|
||||
C --> D["KisDataCollectionOrchestrator.RunCollectionAsync<br/>(tickers: [005930, 000660, ...])"]
|
||||
|
||||
D --> E["Per-ticker 루프"]
|
||||
E --> F["KisApiPriceSource.GetPriceDataAsync<br/>(ticker, account)"]
|
||||
F --> G["PriceDataNormalizer.NormalizeCollectionRow<br/>(seedRow, kisResult)"]
|
||||
G --> H["CollectionRepository.SaveSnapshot<br/>(kis_collection_snapshots)"]
|
||||
G --> I["CollectionRepository.SaveError<br/>(kis_collection_errors, on exception)"]
|
||||
|
||||
H --> J{루프 끝?}
|
||||
I --> J
|
||||
J -->|Yes| K["CollectionRepository.SaveRun<br/>(kis_collection_runs)"]
|
||||
J -->|No| E
|
||||
|
||||
K --> L["파일 출력:<br/>Temp/kis_dotnet_collection_v1.json"]
|
||||
L --> M["Serilog 로그:<br/>src/dotnet/.../logs/"]
|
||||
|
||||
M --> N["Admin UI: /Admin/Collection<br/>(CollectionRepository 읽기)"]
|
||||
N --> O["대시보드 표시:<br/>상태, 스냅샷 수, 에러"]
|
||||
```
|
||||
|
||||
**데이터 흐름**:
|
||||
1. **입력**: Hangfire 스케줄 or API 수동 요청
|
||||
2. **오케스트레이션**: ICollectionOrchestrator 스코프 생성
|
||||
3. **수집**: KIS Open API 호출 → PriceDataNormalizer → DB 저장
|
||||
4. **출력**:
|
||||
- kis_collection_runs: 실행 메타데이터 (run_id, status, total_snapshots, total_errors)
|
||||
- kis_collection_snapshots: 종목별 가격 데이터 (JSON payload)
|
||||
- kis_collection_errors: 에러 기록
|
||||
- Temp/kis_dotnet_collection_v1.json: 수집 결과 요약 (formula_id, gate, run_id, summary)
|
||||
- Serilog 로그: 런타임 로그 (src/dotnet/QuantEngine.Web/logs/)
|
||||
5. **표시**: Admin UI에서 CollectionRepository API 호출 → kis_collection_* 읽기 → Dashboard 렌더링
|
||||
|
||||
---
|
||||
|
||||
## 3. WBS 증거 검증 시퀀스도 (Sequence Diagram)
|
||||
|
||||
작업 완료 증거를 자동 검증하는 파이프라인. 도구: `verify_wbs_task_v1.py` (증거 수집) + `validate_quant_engine_wbs_v1.py` (CI에서 재검증).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
Developer->>verify_wbs_task_v1.py: python verify_wbs_task_v1.py --task QE-M1-01<br/>(또는 --run-commands)
|
||||
verify_wbs_task_v1.py->>+spec/60_quant_engine_wbs.yaml: load spec
|
||||
spec/60_quant_engine_wbs.yaml-->>-verify_wbs_task_v1.py: meta + tasks[QE-M1-01]
|
||||
|
||||
Note over verify_wbs_task_v1.py: evidence_checks 선언형 해석
|
||||
|
||||
alt pg_query 체크
|
||||
verify_wbs_task_v1.py->>+PostgreSQL: SELECT ... (WHERE 절)
|
||||
PostgreSQL-->>-verify_wbs_task_v1.py: 스칼라 결과 또는 행
|
||||
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: expect{min,max,equals} 비교
|
||||
end
|
||||
|
||||
alt log_pattern 체크
|
||||
verify_wbs_task_v1.py->>+src/dotnet/.../logs/: file_glob 매칭
|
||||
src/dotnet/.../logs/-->>-verify_wbs_task_v1.py: 로그 라인
|
||||
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 정규식 패턴 검사<br/>(min_matches, max_age_hours)
|
||||
end
|
||||
|
||||
alt json_gate 체크
|
||||
verify_wbs_task_v1.py->>+Temp/kis_dotnet_collection_v1.json: read JSON
|
||||
Temp/kis_dotnet_collection_v1.json-->>-verify_wbs_task_v1.py: payload
|
||||
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 점 표기 경로(dot notation)<br/>+ 값 비교 (>=N 지원)
|
||||
end
|
||||
|
||||
alt file_exists 체크
|
||||
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: paths[] 존재 확인<br/>(min_bytes 검증)
|
||||
end
|
||||
|
||||
alt playwright_report 체크
|
||||
verify_wbs_task_v1.py->>+tests/e2e/playwright-report.json: read report
|
||||
tests/e2e/playwright-report.json-->>-verify_wbs_task_v1.py: suites[].specs[]
|
||||
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: spec_file 매칭<br/>(passed_min, failed)
|
||||
end
|
||||
|
||||
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 모든 체크 결과 종합<br/>(gate = ALL PASS? → PASS : FAIL)
|
||||
|
||||
verify_wbs_task_v1.py->>+Temp/evidence/QE-M1-01/: mkdir
|
||||
verify_wbs_task_v1.py->>Temp/evidence/QE-M1-01/verdict.json: write verdict<br/>(task_id, gate, checks[])
|
||||
verify_wbs_task_v1.py->>Temp/evidence/QE-M1-01/: save raw evidence<br/>(pg_query_n.json, log_excerpt.txt, ...)
|
||||
|
||||
verify_wbs_task_v1.py->>+runtime/lineage_events.jsonl: append event<br/>(node_id, gate, timestamp)
|
||||
|
||||
Developer<<--verify_wbs_task_v1.py: exit 0 (gate=PASS)<br/>or exit 1 (gate=FAIL)
|
||||
|
||||
Note over Developer: 선택: --run-commands 플래그<br/>verification_commands[] 실행
|
||||
|
||||
Developer->>+validate_quant_engine_wbs_v1.py: (CI) python validate_quant_engine_wbs_v1.py
|
||||
validate_quant_engine_wbs_v1.py->>validate_quant_engine_wbs_v1.py: spec load
|
||||
validate_quant_engine_wbs_v1.py->>validate_quant_engine_wbs_v1.py: tasks[status==DONE] 필터
|
||||
validate_quant_engine_wbs_v1.py->>+Temp/evidence/*/verdict.json: load all verdicts
|
||||
Temp/evidence/*/verdict.json-->>-validate_quant_engine_wbs_v1.py: gate 값
|
||||
validate_quant_engine_wbs_v1.py->>validate_quant_engine_wbs_v1.py: gate=FAIL? → CI FAIL
|
||||
validate_quant_engine_wbs_v1.py->>+Temp/quant_engine_wbs_v1.json: write summary
|
||||
Developer<<--validate_quant_engine_wbs_v1.py: exit 0 (모두 PASS)<br/>or exit 1 (일부 FAIL)
|
||||
```
|
||||
|
||||
**검증 프로세스 상세**:
|
||||
|
||||
| 단계 | 역할 | 산출물 |
|
||||
|------|------|--------|
|
||||
| **1. 스펙 로드** | verify_wbs_task_v1.py | spec/60_quant_engine_wbs.yaml |
|
||||
| **2. 증거 체크 실행** | 선언형 evidence_checks[] | pg_query / log_pattern / json_gate / file_exists / playwright_report |
|
||||
| **3. 게이트 결정** | 모든 체크 PASS? | gate = PASS or FAIL |
|
||||
| **4. 증거 저장** | Temp/evidence/<TASK_ID>/ | verdict.json + 원시 증거 |
|
||||
| **5. 계보 로깅** | runtime/lineage_events.jsonl | node_id, gate, timestamp |
|
||||
| **6. CI 재검증** | validate_quant_engine_wbs_v1.py | status=DONE 작업만 재검증 |
|
||||
|
||||
**주요 특징**:
|
||||
- **선언형 검증**: 체크 로직을 YAML에 기술 (하드코딩 최소화)
|
||||
- **원시 증거 보존**: 각 체크의 상세 결과를 JSON/텍스트로 저장
|
||||
- **완료 주장 차단**: "완료했다"는 수동 선언 불가 → verdict.json gate=PASS만 인정
|
||||
- **CI 편입**: validate_quant_engine_wbs_v1.py가 release DAG의 노드로 동작
|
||||
- **멀티 트리거**: 단일 작업 검증 (--task) 또는 전체 검증 (CI)
|
||||
|
||||
**검증 체크 타입 참고** (spec/60_quant_engine_wbs.yaml "evidence_check_types"):
|
||||
- **pg_query**: PostgreSQL 스칼라 결과 비교 (min/max/equals)
|
||||
- **log_pattern**: 로그 파일 정규식 매칭 (min_matches, max_age_hours)
|
||||
- **json_gate**: JSON 아티팩트 키-값 검사 (점 표기 경로, >=N 비교)
|
||||
- **file_exists**: 파일 존재 + 크기 검증 (min_bytes)
|
||||
- **playwright_report**: Playwright 리포트 테스트 결과 (passed_min, failed)
|
||||
+11
-1
@@ -52,7 +52,17 @@
|
||||
"validate-engine-strict": "python tools/run_release_dag_v3.py --mode release --strict",
|
||||
"validate-behavioral-coverage": "python tools/validate_behavioral_coverage_v1.py --strict",
|
||||
"validate-engine-integrity": "python tools/run_release_dag_v3.py --mode release --strict",
|
||||
"render-report-json": "dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json"
|
||||
"render-report-json": "dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json",
|
||||
"verify:task": "python tools/verify_wbs_task_v1.py --task",
|
||||
"collect:remote-evidence": "python tools/collect_remote_wbs_evidence_v1.py",
|
||||
"verify:wbs": "python tools/validate_quant_engine_wbs_v1.py",
|
||||
"validate:normalized-learning-store": "python tools/validate_normalized_learning_store_v1.py",
|
||||
"validate:dotnet-cutover": "python tools/validate_dotnet_postgresql_json_cutover_v1.py",
|
||||
"validate:schema-model": "python tools/generate_schema_model_generation_evidence_v1.py && python tools/validate_schema_model_generation_v1.py",
|
||||
"validate:runtime-settings": "python tools/validate_runtime_connection_settings_immutability_v1.py",
|
||||
"validate:market-schema": "python tools/validate_market_time_series_schema_v1.py",
|
||||
"test:e2e": "playwright test --project=chromium",
|
||||
"test:evidence": "playwright test --project=evidence"
|
||||
},
|
||||
"dependencies": {
|
||||
"cheerio": "1.2.0",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testIgnore: '**/archive/**',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
@@ -14,7 +15,7 @@ export default defineConfig({
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'list',
|
||||
reporter: [['list'], ['json', { outputFile: 'Temp/evidence/playwright-last-run.json' }]],
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
@@ -29,8 +30,14 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
testIgnore: ['**/archive/**', '**/evidence/**'],
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'evidence',
|
||||
testDir: './tests/e2e/evidence',
|
||||
use: { ...devices['Desktop Chrome'], screenshot: 'on', trace: 'on' },
|
||||
},
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
|
||||
@@ -176,7 +176,9 @@ quant_feed_contract:
|
||||
database_first_operating_model:
|
||||
purpose: "운영 이력, 원천 팩터, 파생 최종 팩터, 시장-결과 괴리를 PostgreSQL에 누적해 엔진을 고도화한다."
|
||||
canonical_store:
|
||||
primary: "PostgreSQL"
|
||||
primary: "PostgreSQL"
|
||||
canonical_engine_history_migration: "src/dotnet/QuantEngine.Infrastructure/Migrations/V5__Add_Normalized_Learning_History.sql"
|
||||
learning_projection: "engine_history.training_example_v1"
|
||||
secondary: "SQLite transient cache only"
|
||||
prohibited_operating_path:
|
||||
- "Excel workbook as operational source"
|
||||
@@ -190,6 +192,8 @@ quant_feed_contract:
|
||||
policy:
|
||||
- "최종 팩터와 최종 판단은 DB 이력 테이블에 버전과 시각을 함께 남긴다."
|
||||
- "시장 raw와 엔진 결과의 괴리는 별도 gap history로 적재한다."
|
||||
- "원천 관측·factor·decision·outcome은 정규화된 PostgreSQL event store에 적재한다."
|
||||
- "학습/캘리브레이션 입력은 training_example_v1 역정규화 view에서만 생성한다."
|
||||
- "엑셀/시트/Apps Script는 더 이상 운영 경로가 아니라, 역사적 import/export 또는 폐기 대상만 허용한다."
|
||||
- "새 분석·리포트는 PostgreSQL snapshot을 1차 진실원천으로 사용한다."
|
||||
xlsx_analysis_protocol:
|
||||
|
||||
@@ -2280,6 +2280,23 @@ dag:
|
||||
strict: false
|
||||
timeout_sec: 60
|
||||
warn_only: true
|
||||
validate_quant_engine_wbs:
|
||||
artifact_policy: keep
|
||||
cache_key: validate_quant_engine_wbs_v1
|
||||
command:
|
||||
- python
|
||||
- tools/validate_quant_engine_wbs_v1.py
|
||||
depends_on: []
|
||||
id: validate_quant_engine_wbs
|
||||
inputs:
|
||||
- tools/validate_quant_engine_wbs_v1.py
|
||||
- spec/60_quant_engine_wbs.yaml
|
||||
note: 퀀트 엔진 WBS 증거 게이트 — status=DONE 작업은 Temp/evidence/<TASK_ID>/verdict.json
|
||||
gate=PASS 가 있어야 한다 (완료 주장 금지, 게이트 실행으로만 DONE).
|
||||
outputs:
|
||||
- Temp/quant_engine_wbs_v1.json
|
||||
strict: true
|
||||
timeout_sec: 60
|
||||
validate_specs:
|
||||
artifact_policy: keep
|
||||
cache_key: validate_specs_v1
|
||||
@@ -2327,6 +2344,7 @@ execution_order:
|
||||
- validate_metric_alias_collision
|
||||
- validate_packaged_refs
|
||||
- validate_property_invariants
|
||||
- validate_quant_engine_wbs
|
||||
- validate_renderer_no_calc
|
||||
- validate_runtime_source_whitelist
|
||||
- validate_sector_universe_monthly_refresh
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
# =============================================================================
|
||||
# QuantEngine 데이터 실증 기반 퀀트 엔진 로드맵 + WBS (기계 판정)
|
||||
# =============================================================================
|
||||
# formula_id: QUANT_ENGINE_WBS_V1
|
||||
# 원칙: 모든 작업(task)은 "완료 주장"이 아니라 게이트 실행으로만 DONE 판정된다.
|
||||
# - BE 실증: pg_query(PostgreSQL 쿼리) + log_pattern(Serilog 로그) + json_gate(아티팩트)
|
||||
# - FE 실증: playwright_report(스펙 PASS) + file_exists(스크린샷)
|
||||
# 실행:
|
||||
# 단일 작업 검증: python tools/verify_wbs_task_v1.py --task <TASK_ID>
|
||||
# → Temp/evidence/<TASK_ID>/verdict.json + 원시 증거 보존
|
||||
# 전체 WBS 게이트: python tools/validate_quant_engine_wbs_v1.py
|
||||
# → Temp/quant_engine_wbs_v1.json (status=DONE 작업의 증거 재검증)
|
||||
# 관례: spec/16_data_gaps_roadmap.yaml 의 success_criteria 구조
|
||||
# (expected_success_value / evidence_artifacts / verification_commands) 준수.
|
||||
# 검증 로직만 하드코딩 → evidence_checks 선언형으로 일반화.
|
||||
# =============================================================================
|
||||
|
||||
meta:
|
||||
formula_id: QUANT_ENGINE_WBS_V1
|
||||
version: 1
|
||||
created: "2026-07-12"
|
||||
authority: "governance/authority_matrix.yaml"
|
||||
validator: tools/validate_quant_engine_wbs_v1.py
|
||||
task_verifier: tools/verify_wbs_task_v1.py
|
||||
remote_evidence_collector: tools/collect_remote_wbs_evidence_v1.py
|
||||
evidence_root: Temp/evidence
|
||||
status_values: [PENDING, IN_PROGRESS, DONE] # DONE = 해당 verdict.json gate=PASS 필수
|
||||
db_connection:
|
||||
# 검증기의 PostgreSQL 접속 순서:
|
||||
# 1) env QE_WBS_PG_DSN (psycopg DSN)
|
||||
# 2) env ConnectionStrings__DefaultConnection (.NET 형식 → 자동 변환)
|
||||
# 3) src/dotnet/QuantEngine.Web/appsettings.Development.json 의 ConnectionStrings.DefaultConnection
|
||||
# (로컬은 SSH 터널 127.0.0.1:5432 전제 — CLAUDE.md "Local Development & Testing")
|
||||
dotnet_appsettings: src/dotnet/QuantEngine.Web/appsettings.Development.json
|
||||
remote_evidence:
|
||||
collector: "python tools/collect_remote_wbs_evidence_v1.py --target <ssh-target>"
|
||||
policy: "Collect journal and JSON artifacts only; never copy env files or passwords."
|
||||
postgres: "Use QE_WBS_PG_DSN through an approved SSH tunnel; do not embed credentials in evidence."
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 검증 체크 타입 사전 (verify_wbs_task_v1.py 가 해석하는 선언형 vocabulary)
|
||||
# -----------------------------------------------------------------------------
|
||||
evidence_check_types:
|
||||
pg_query: "PostgreSQL 쿼리 1개 실행, 단일 스칼라 결과를 expect{min,max,equals}와 비교. 원시 결과를 pg_query_<n>.json 으로 보존"
|
||||
log_pattern: "file_glob 로그 파일들에서 정규식 매칭. expect{min_matches, max_age_hours(파일 mtime 기준)}. 매칭 라인을 log_excerpt.txt 로 보존"
|
||||
json_gate: "path 의 JSON 아티팩트에서 expect 의 키-값 검사 (점 표기 경로 지원, 값 '>=N' 비교 지원)"
|
||||
file_exists: "paths 의 모든 파일 존재 (expect.min_bytes 선택)"
|
||||
playwright_report: "Playwright JSON 리포트(report)에서 spec_file 의 결과가 expect{passed_min, failed} 충족"
|
||||
|
||||
# =============================================================================
|
||||
# 로드맵 (M0 → M5)
|
||||
# =============================================================================
|
||||
roadmap:
|
||||
scope_note: >
|
||||
전통 팩터(모멘텀/거래량/수급/실적/매크로/밸류/재무건전성 = spec/08_scoring_rules.yaml SS001)
|
||||
+ ATR 리스크 관리 기본 포함. 최신 기법은 레짐 감지 + 워크포워드 캘리브레이션 + 거래비용
|
||||
반영 평가로 한정(사용자 확정, 2026-07-12). 딥러닝/인트라데이/대체데이터/실거래 집행 제외
|
||||
(은퇴자산 + read-only KIS 거버넌스: governance/rules/06_no_direct_api_trading.yaml 유지).
|
||||
phases:
|
||||
M0:
|
||||
name: "실증 하네스 + 정직성 정리"
|
||||
goal: "완료 주장이 불가능한 구조 확립 — 검증기/증거 규약/CI 편입 + 가짜 검증 제거"
|
||||
exit_gate: "validate_quant_engine_wbs 가 release DAG/CI 노드로 PASS; dotnet test + Playwright evidence 스위트 CI 편입; 디버그 스펙 격리·가짜 PASS 제거"
|
||||
tasks: [QE-M0-01, QE-M0-02, QE-M0-03, QE-M0-04, QE-M0-05, QE-M0-06]
|
||||
M1:
|
||||
name: "수집 파이프라인 배선"
|
||||
goal: "운영 앱이 실제 KIS 데이터를 수집하도록 고아 오케스트레이터 배선 (첫 실데이터 실증)"
|
||||
exit_gate: "Hangfire daily-collection + POST /api/collection/run 으로 kis_collection_* 에 실데이터 적재, Admin Collection 페이지 Playwright 실증"
|
||||
tasks: [QE-M1-01, QE-M1-02, QE-M1-03, QE-M1-04, QE-M1-05]
|
||||
M2:
|
||||
name: "히스토리 시계열 저장소"
|
||||
goal: "모멘텀 팩터·백테스트의 전제인 일봉/매크로 시계열 축적 (2년 백필)"
|
||||
exit_gate: "price_history_daily/macro_history_daily 에 유니버스 2년치; (ticker,date) 중복 0; 거래일 캘린더 대비 gap 0"
|
||||
tasks: [QE-M2-01, QE-M2-02, QE-M2-03, QE-M2-04, QE-M2-05]
|
||||
M3:
|
||||
name: "실데이터 팩터 계산"
|
||||
goal: "SS001 전통 팩터를 PG 히스토리에서 계산해 engine_history 에 적재, 파일 개수 골든커버리지를 수치 패리티로 대체"
|
||||
exit_gate: "factor_output_history 에 유니버스 전체 스코어(0-100); Python 참조 대비 패리티 ≥20 formula tol 1e-9 PASS"
|
||||
tasks: [QE-M3-01, QE-M3-02, QE-M3-03, QE-M3-04, QE-M3-05]
|
||||
M4:
|
||||
name: "백테스팅 + 검증"
|
||||
goal: "point-in-time 데이터만 사용하는 워크포워드 백테스터 + 거래비용 모델 + no-lookahead 게이트 실배선"
|
||||
exit_gate: "Sharpe/MDD/턴오버 JSON 산출; no-lookahead 정상 PASS + 오염 픽스처 FAIL 양방향; T+5/T+20 원장 표본 ≥30"
|
||||
tasks: [QE-M4-01, QE-M4-02, QE-M4-03, QE-M4-04, QE-M4-05]
|
||||
M5:
|
||||
name: "포트폴리오 구성 + 최신 기법"
|
||||
goal: "레짐 감지 + SS001 가중치 워크포워드 캘리브레이션(제약+shrinkage) + 변동성 타게팅 사이징"
|
||||
exit_gate: "백필 전 기간 레짐 라벨; 캘리브레이션 가중치 제약 준수 + OOS Sharpe 정직 보고; 최종 포트폴리오 패킷 캡 준수"
|
||||
tasks: [QE-M5-01, QE-M5-02, QE-M5-03, QE-M5-04]
|
||||
|
||||
# =============================================================================
|
||||
# WBS 작업 목록
|
||||
# =============================================================================
|
||||
tasks:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M0 — 실증 하네스 + 정직성 정리
|
||||
# ---------------------------------------------------------------------------
|
||||
QE-M0-01:
|
||||
title: "WBS 스펙(YAML) + 로드맵 작성, 레거시 로드맵 문서에 포인터 추가"
|
||||
status: DONE
|
||||
depends_on: []
|
||||
owner_files:
|
||||
- spec/60_quant_engine_wbs.yaml
|
||||
- docs/ROADMAP_WBS.md
|
||||
success_criteria:
|
||||
expected_success_value: { spec_exists: true, legacy_pointer_appended: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M0-01/verdict.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M0-01"]
|
||||
evidence_checks:
|
||||
- type: file_exists
|
||||
paths: [spec/60_quant_engine_wbs.yaml]
|
||||
expect: { min_bytes: 10000 }
|
||||
- type: log_pattern
|
||||
file_glob: docs/ROADMAP_WBS.md
|
||||
pattern: "QUANT_ENGINE_WBS_V1"
|
||||
expect: { min_matches: 1 }
|
||||
|
||||
QE-M0-02:
|
||||
title: "증거 검증기 2종 구현 (단일 작업 verifier + 전체 WBS validator) + 유닛테스트"
|
||||
status: DONE
|
||||
depends_on: []
|
||||
owner_files:
|
||||
- tools/verify_wbs_task_v1.py
|
||||
- tools/validate_quant_engine_wbs_v1.py
|
||||
- tests/unit/test_validate_quant_engine_wbs_v1.py
|
||||
success_criteria:
|
||||
expected_success_value: { self_test: PASS, synthetic_pass_fail_bidirectional: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M0-02/verdict.json, Temp/quant_engine_wbs_v1.json]
|
||||
verification_commands:
|
||||
- "python -m pytest tests/unit/test_validate_quant_engine_wbs_v1.py -q"
|
||||
- "python tools/verify_wbs_task_v1.py --task QE-M0-02"
|
||||
evidence_checks:
|
||||
- type: file_exists
|
||||
paths:
|
||||
- tools/verify_wbs_task_v1.py
|
||||
- tools/validate_quant_engine_wbs_v1.py
|
||||
- tests/unit/test_validate_quant_engine_wbs_v1.py
|
||||
- type: log_pattern
|
||||
file_glob: tools/validate_quant_engine_wbs_v1.py
|
||||
pattern: "def main"
|
||||
expect: { min_matches: 1 }
|
||||
|
||||
QE-M0-03:
|
||||
title: "Playwright 정직성 정리 + evidence 프로젝트 + npm 스크립트"
|
||||
status: DONE
|
||||
depends_on: [QE-M0-02]
|
||||
owner_files:
|
||||
- playwright.config.ts
|
||||
- package.json
|
||||
- tests/e2e/archive/
|
||||
notes: >
|
||||
디버그 스펙(~17개: debug-login, html-debug, wasm-test, framework-check, console-check,
|
||||
screenshot-diagnosis, inspect-page, login* 변형 등)을 tests/e2e/archive/ 로 이동하고
|
||||
testIgnore 로 제외. full-validation.spec.ts 의 assert 없는 가짜 "[PASS]" 배너 테스트
|
||||
제거(파일째 archive). evidence 프로젝트: testDir tests/e2e/evidence, screenshot 'on',
|
||||
trace 'on', JSON reporter → Temp/evidence/playwright-last-run.json.
|
||||
npm 스크립트: verify:task / verify:wbs / test:e2e / test:evidence
|
||||
success_criteria:
|
||||
expected_success_value: { default_project_specs: ["admin-pages.spec.ts"], fake_pass_removed: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M0-03/verdict.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M0-03"]
|
||||
evidence_checks:
|
||||
- type: file_exists
|
||||
paths: [tests/e2e/archive]
|
||||
- type: log_pattern
|
||||
file_glob: playwright.config.ts
|
||||
pattern: "evidence"
|
||||
expect: { min_matches: 1 }
|
||||
- type: log_pattern
|
||||
file_glob: package.json
|
||||
pattern: "verify:task"
|
||||
expect: { min_matches: 1 }
|
||||
- type: log_pattern
|
||||
file_glob: tests/e2e/full-validation.spec.ts
|
||||
pattern: ".*"
|
||||
expect: { max_matches: 0 } # 파일이 기본 testDir 에 더 이상 존재하지 않아야 함
|
||||
|
||||
QE-M0-04:
|
||||
title: "CI에 dotnet test 편입 + 고아 QuantEngine.Web.Tests 처리"
|
||||
status: DONE
|
||||
depends_on: []
|
||||
owner_files:
|
||||
- .gitea/workflows/ci.yml
|
||||
- src/dotnet/QuantEngine.Web.Tests/
|
||||
notes: >
|
||||
QuantEngine.Web.Tests/DashboardComponentTests.cs 는 csproj 없는 고아(폐기된 Blazor 대상).
|
||||
현 Razor Pages UI 에 맞지 않으면 삭제. ci.yml 에 dotnet test 스텝 추가.
|
||||
success_criteria:
|
||||
expected_success_value: { ci_has_dotnet_test: true, core_tests_green: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M0-04/verdict.json]
|
||||
verification_commands:
|
||||
- "dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Release --nologo"
|
||||
- "python tools/verify_wbs_task_v1.py --task QE-M0-04"
|
||||
evidence_checks:
|
||||
- type: log_pattern
|
||||
file_glob: .gitea/workflows/ci.yml
|
||||
pattern: "dotnet test"
|
||||
expect: { min_matches: 1 }
|
||||
|
||||
QE-M0-05:
|
||||
title: "release DAG + CI 에 validate_quant_engine_wbs 게이트 노드 등록"
|
||||
status: DONE
|
||||
depends_on: [QE-M0-02]
|
||||
owner_files:
|
||||
- spec/41_release_dag.yaml
|
||||
- .gitea/workflows/ci.yml
|
||||
success_criteria:
|
||||
expected_success_value: { dag_node: validate_quant_engine_wbs, ci_step: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M0-05/verdict.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M0-05"]
|
||||
evidence_checks:
|
||||
- type: log_pattern
|
||||
file_glob: spec/41_release_dag.yaml
|
||||
pattern: "validate_quant_engine_wbs"
|
||||
expect: { min_matches: 1 }
|
||||
- type: log_pattern
|
||||
file_glob: .gitea/workflows/ci.yml
|
||||
pattern: "validate_quant_engine_wbs_v1"
|
||||
expect: { min_matches: 1 }
|
||||
|
||||
QE-M0-06:
|
||||
title: "골든커버리지 정직성 표기 (coverage_basis: FILE_COUNT_ONLY)"
|
||||
status: DONE
|
||||
depends_on: []
|
||||
owner_files:
|
||||
- tools/validate_golden_coverage_100.py
|
||||
notes: >
|
||||
골든 테스트 174개는 실행되지 않는 placeholder. 커버리지 판정 출력에
|
||||
coverage_basis: FILE_COUNT_ONLY 필드를 추가해 실체를 명시(삭제는 M3 패리티 대체 후).
|
||||
success_criteria:
|
||||
expected_success_value: { honesty_field: FILE_COUNT_ONLY }
|
||||
evidence_artifacts: [Temp/evidence/QE-M0-06/verdict.json]
|
||||
verification_commands:
|
||||
- "python tools/validate_golden_coverage_100.py"
|
||||
- "python tools/verify_wbs_task_v1.py --task QE-M0-06"
|
||||
evidence_checks:
|
||||
- type: json_gate
|
||||
path: Temp/golden_coverage_100_v1.json
|
||||
expect: { coverage_basis: FILE_COUNT_ONLY }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M1 — 수집 파이프라인 배선 (첫 실데이터 실증)
|
||||
# ---------------------------------------------------------------------------
|
||||
QE-M1-01:
|
||||
title: "KisDataCollectionOrchestrator DI 등록 + daily-collection Hangfire 잡 실구현"
|
||||
status: PENDING
|
||||
depends_on: [QE-M0-02]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Web/Program.cs
|
||||
- src/dotnet/QuantEngine.Web/Services/SchedulerService.cs
|
||||
notes: >
|
||||
Program.cs 에 PriceDataNormalizer / SourcePriorityResolver / ICollectionOrchestrator →
|
||||
KisDataCollectionOrchestrator 등록. RunDailyCollectionAsync 의 Task.Delay 시뮬레이션을
|
||||
IServiceScopeFactory 스코프 → 오케스트레이터 호출로 교체 (runId "daily-yyyyMMdd-HHmmss").
|
||||
완료 로그: "Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors".
|
||||
상태값은 대문자 COMPLETED / COMPLETED_WITH_ERRORS (KisDataCollectionOrchestrator.cs:103).
|
||||
success_criteria:
|
||||
expected_success_value: { runs_completed_min: 1, snapshots_min: 5, hangfire_job: daily-collection }
|
||||
evidence_artifacts: [Temp/evidence/QE-M1-01/verdict.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-01"]
|
||||
evidence_checks:
|
||||
- type: pg_query
|
||||
sql: >
|
||||
SELECT count(*) FROM quantengine.kis_collection_runs
|
||||
WHERE status LIKE 'COMPLETED%' AND total_snapshots >= 5
|
||||
AND started_at >= (now() - interval '24 hours')::text
|
||||
expect: { min: 1 }
|
||||
- type: pg_query
|
||||
sql: >
|
||||
SELECT count(DISTINCT s.ticker) FROM quantengine.kis_collection_snapshots s
|
||||
JOIN quantengine.kis_collection_runs r ON r.run_id = s.run_id
|
||||
WHERE r.started_at >= (now() - interval '24 hours')::text
|
||||
expect: { min: 5 }
|
||||
- type: log_pattern
|
||||
file_glob: src/dotnet/QuantEngine.Web/logs/quantengine-*.log
|
||||
pattern: 'Collection run .+ completed: \d+ snapshots'
|
||||
expect: { min_matches: 1, max_age_hours: 24 }
|
||||
- type: json_gate
|
||||
path: Temp/kis_dotnet_collection_v1.json
|
||||
expect: { gate: PASS }
|
||||
|
||||
QE-M1-02:
|
||||
title: "Admin Collection 페이지 FE 실증 (실제 run 렌더링을 Playwright 로 증명)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M1-01, QE-M0-03]
|
||||
owner_files:
|
||||
- tests/e2e/evidence/qe-m1-02-collection-run.spec.ts
|
||||
notes: >
|
||||
필수 3요소: (a) 기대값을 /api/collection/runs API 에서 조회(하드코딩 금지),
|
||||
(b) /Admin/Collection DOM 에서 run_id·스냅샷 수·상태 배지를 기대값과 assert,
|
||||
(c) assert 시점 스크린샷 → Temp/evidence/QE-M1-02/screenshots/{01-collection-page,02-run-detail}.png
|
||||
success_criteria:
|
||||
expected_success_value: { spec_passed: 1, screenshots: 2, dom_equals_api: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M1-02/verdict.json]
|
||||
verification_commands:
|
||||
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m1-02-collection-run.spec.ts"
|
||||
- "python tools/verify_wbs_task_v1.py --task QE-M1-02"
|
||||
evidence_checks:
|
||||
- type: playwright_report
|
||||
report: Temp/evidence/playwright-last-run.json
|
||||
spec_file: qe-m1-02-collection-run.spec.ts
|
||||
expect: { passed_min: 1, failed: 0 }
|
||||
- type: file_exists
|
||||
paths:
|
||||
- Temp/evidence/QE-M1-02/screenshots/01-collection-page.png
|
||||
- Temp/evidence/QE-M1-02/screenshots/02-run-detail.png
|
||||
expect: { min_bytes: 10000 }
|
||||
|
||||
QE-M1-03:
|
||||
title: "POST /api/collection/run 실구현 (BackgroundJob.Enqueue + 인증 필수화)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M1-01]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs
|
||||
notes: >
|
||||
202 no-op 스텁을 Hangfire BackgroundJob.Enqueue(오케스트레이터 실행)로 교체,
|
||||
응답에 {runId} 포함. AllowAnonymous 제거(쿠키 인증).
|
||||
로그: "Collection run {RunId} enqueued".
|
||||
success_criteria:
|
||||
expected_success_value: { returns_run_id: true, auth_required: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M1-03/verdict.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-03"]
|
||||
evidence_checks:
|
||||
- type: log_pattern
|
||||
file_glob: src/dotnet/QuantEngine.Web/logs/quantengine-*.log
|
||||
pattern: 'Collection run .+ enqueued'
|
||||
expect: { min_matches: 1, max_age_hours: 24 }
|
||||
- type: pg_query
|
||||
sql: >
|
||||
SELECT count(*) FROM quantengine.kis_collection_runs
|
||||
WHERE run_id LIKE 'api-%' AND started_at >= (now() - interval '24 hours')::text
|
||||
expect: { min: 1 }
|
||||
|
||||
QE-M1-04:
|
||||
title: "오케스트레이터 로깅 복원 + 출력 아티팩트 표준화 + 멀티소스 폴백 배선"
|
||||
status: PENDING
|
||||
depends_on: [QE-M1-01]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs
|
||||
notes: >
|
||||
"// Log: skipped" → ILogger<KisDataCollectionOrchestrator> 복원.
|
||||
출력 경로 Path.GetTempPath() → <repo>/Temp/kis_dotnet_collection_v1.json,
|
||||
형식 {formula_id: KIS_DOTNET_COLLECTION_V1, gate, summary{success_count, error_count, source_counts}}.
|
||||
SourcePriorityResolver 를 통해 Naver/Yahoo 폴백 경로 활성화.
|
||||
success_criteria:
|
||||
expected_success_value: { gate: PASS, source_counts_min: 1, error_rows_on_bad_ticker: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M1-04/verdict.json, Temp/kis_dotnet_collection_v1.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-04"]
|
||||
evidence_checks:
|
||||
- type: json_gate
|
||||
path: Temp/kis_dotnet_collection_v1.json
|
||||
expect: { formula_id: KIS_DOTNET_COLLECTION_V1, gate: PASS }
|
||||
- type: log_pattern
|
||||
file_glob: src/dotnet/QuantEngine.Web/logs/quantengine-*.log
|
||||
pattern: 'Collecting ticker'
|
||||
expect: { min_matches: 1, max_age_hours: 24 }
|
||||
|
||||
QE-M1-05:
|
||||
title: "티커 유니버스를 GatherTradingData 파서/DB 설정에서 로드 (하드코딩 제거)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M1-01]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Web/Services/SchedulerService.cs
|
||||
success_criteria:
|
||||
expected_success_value: { distinct_tickers_equals_universe: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M1-05/verdict.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-05"]
|
||||
evidence_checks:
|
||||
- type: log_pattern
|
||||
file_glob: src/dotnet/QuantEngine.Web/Services/SchedulerService.cs
|
||||
pattern: '005930.+000660.+051910'
|
||||
expect: { max_matches: 0 } # 하드코딩 티커 배열 부재
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M2 — 히스토리 시계열 저장소
|
||||
# ---------------------------------------------------------------------------
|
||||
QE-M2-01:
|
||||
title: "V6 마이그레이션: price_history_daily + macro_history_daily"
|
||||
status: PENDING
|
||||
depends_on: [QE-M1-01]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Infrastructure/Migrations/V6__Add_Market_Time_Series.sql
|
||||
notes: >
|
||||
price_history_daily(ticker, trade_date, open/high/low/close numeric, volume bigint,
|
||||
source text, collected_at timestamptz, PK(ticker, trade_date));
|
||||
macro_history_daily(symbol, trade_date, value numeric, source, PK(symbol, trade_date)).
|
||||
DbUp 마이그레이션 추가 시 docs/db/quantengine.dbml 동기화 필수 (CLAUDE.md 규칙 — 아래 체크로 강제).
|
||||
success_criteria:
|
||||
expected_success_value: { tables_created: 2 }
|
||||
evidence_artifacts: [Temp/evidence/QE-M2-01/verdict.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M2-01"]
|
||||
evidence_checks:
|
||||
- type: pg_query
|
||||
sql: >
|
||||
SELECT count(*) FROM information_schema.tables
|
||||
WHERE table_schema='quantengine' AND table_name IN ('price_history_daily','macro_history_daily')
|
||||
expect: { equals: 2 }
|
||||
- type: log_pattern
|
||||
file_glob: docs/db/quantengine.dbml
|
||||
pattern: 'price_history_daily'
|
||||
expect: { min_matches: 1 } # DBML 동기화 강제
|
||||
|
||||
QE-M2-02:
|
||||
title: "일봉 OHLCV 시계열 적재 (daily run 마다 upsert, 재실행 중복 0)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M2-01]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs
|
||||
success_criteria:
|
||||
expected_success_value: { rows_per_ticker_min: 1, duplicate_on_rerun: 0 }
|
||||
evidence_artifacts: [Temp/evidence/QE-M2-02/verdict.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M2-02"]
|
||||
evidence_checks:
|
||||
- type: pg_query
|
||||
sql: "SELECT count(*) FROM quantengine.price_history_daily WHERE collected_at >= now() - interval '24 hours'"
|
||||
expect: { min: 1 }
|
||||
- type: pg_query
|
||||
sql: >
|
||||
SELECT count(*) FROM (SELECT ticker, trade_date, count(*) c
|
||||
FROM quantengine.price_history_daily GROUP BY 1,2 HAVING count(*) > 1) d
|
||||
expect: { equals: 0 }
|
||||
|
||||
QE-M2-03:
|
||||
title: "2년치 백필 툴 (KIS chart API 페이지네이션 + rate-limit, 매크로는 yfinance→PG)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M2-01]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Tools/
|
||||
- src/quant_engine/macro_index_collection_v1.py
|
||||
success_criteria:
|
||||
expected_success_value: { bars_per_ticker_min: 480, macro_bars_min: 480 }
|
||||
evidence_artifacts: [Temp/evidence/QE-M2-03/verdict.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M2-03"]
|
||||
evidence_checks:
|
||||
- type: pg_query
|
||||
sql: "SELECT coalesce(min(c),0) FROM (SELECT count(*) c FROM quantengine.price_history_daily GROUP BY ticker) t"
|
||||
expect: { min: 480 }
|
||||
- type: pg_query
|
||||
sql: "SELECT count(*) FROM quantengine.macro_history_daily WHERE symbol IN ('KOSPI','KOSDAQ')"
|
||||
expect: { min: 960 }
|
||||
|
||||
QE-M2-04:
|
||||
title: "시계열 무결성 게이트 (거래일 캘린더 대비 gap 0, 가격 sanity)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M2-03]
|
||||
owner_files:
|
||||
- tools/validate_price_history_integrity_v1.py
|
||||
success_criteria:
|
||||
expected_success_value: { gap_count: 0, invalid_price_rows: 0 }
|
||||
evidence_artifacts: [Temp/evidence/QE-M2-04/verdict.json, Temp/price_history_integrity_v1.json]
|
||||
verification_commands:
|
||||
- "python tools/validate_price_history_integrity_v1.py"
|
||||
- "python tools/verify_wbs_task_v1.py --task QE-M2-04"
|
||||
evidence_checks:
|
||||
- type: json_gate
|
||||
path: Temp/price_history_integrity_v1.json
|
||||
expect: { gate: PASS, gap_count: 0 }
|
||||
|
||||
QE-M2-05:
|
||||
title: "히스토리 현황 FE (per-ticker bar 수/기간/gap — API 값과 DOM 대조)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M2-03, QE-M0-03]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Web/Pages/Admin/Collection/
|
||||
- tests/e2e/evidence/qe-m2-05-history-tab.spec.ts
|
||||
success_criteria:
|
||||
expected_success_value: { spec_passed: 1, screenshots_min: 1 }
|
||||
evidence_artifacts: [Temp/evidence/QE-M2-05/verdict.json]
|
||||
verification_commands:
|
||||
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m2-05-history-tab.spec.ts"
|
||||
- "python tools/verify_wbs_task_v1.py --task QE-M2-05"
|
||||
evidence_checks:
|
||||
- type: playwright_report
|
||||
report: Temp/evidence/playwright-last-run.json
|
||||
spec_file: qe-m2-05-history-tab.spec.ts
|
||||
expect: { passed_min: 1, failed: 0 }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M3 — 실데이터 팩터 계산
|
||||
# ---------------------------------------------------------------------------
|
||||
QE-M3-01:
|
||||
title: "Point-in-time 리더 (GetBarsAsOf — lookahead 구조적 차단 + xUnit 증명)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M2-03]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Infrastructure/Repositories/
|
||||
- src/dotnet/QuantEngine.Core.Tests/
|
||||
success_criteria:
|
||||
expected_success_value: { asof_leak_tests_green: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M3-01/verdict.json]
|
||||
verification_commands:
|
||||
- "dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Release --filter PriceHistoryReader"
|
||||
- "python tools/verify_wbs_task_v1.py --task QE-M3-01"
|
||||
evidence_checks:
|
||||
- type: log_pattern
|
||||
file_glob: src/dotnet/QuantEngine.Core.Tests/**/*.cs
|
||||
pattern: 'GetBarsAsOf'
|
||||
expect: { min_matches: 1 }
|
||||
|
||||
QE-M3-02:
|
||||
title: "전통 팩터 계산기 (모멘텀 20/60/120d·RS, 저변동성 ATR%·stdev·beta, 밸류/퀄리티)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M3-01]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Core/Domain/
|
||||
- tools/validate_factor_parity_v1.py
|
||||
success_criteria:
|
||||
expected_success_value: { parity_formulas_min: 20, tolerance: 1e-9 }
|
||||
evidence_artifacts: [Temp/evidence/QE-M3-02/verdict.json, Temp/factor_parity_v1.json]
|
||||
verification_commands:
|
||||
- "python tools/validate_factor_parity_v1.py"
|
||||
- "python tools/verify_wbs_task_v1.py --task QE-M3-02"
|
||||
evidence_checks:
|
||||
- type: json_gate
|
||||
path: Temp/factor_parity_v1.json
|
||||
expect: { gate: PASS, compared_count: ">=20" }
|
||||
|
||||
QE-M3-03:
|
||||
title: "SS001 합성 스코어 + HF001-09 → engine_history.factor_output_history 적재"
|
||||
status: PENDING
|
||||
depends_on: [QE-M3-02]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Application/Services/
|
||||
success_criteria:
|
||||
expected_success_value: { scored_universe_full: true, score_range_0_100: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M3-03/verdict.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M3-03"]
|
||||
evidence_checks:
|
||||
- type: pg_query
|
||||
sql: "SELECT count(*) FROM engine_history.factor_output_history WHERE created_at >= now() - interval '24 hours'"
|
||||
expect: { min: 5 }
|
||||
|
||||
QE-M3-04:
|
||||
title: "PipelineOrchestrator 정직화 (1-2단계 실구현, 나머지 STUBBED 표기 — mock PASS 금지)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M3-03]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Application/Services/PipelineOrchestrator.cs
|
||||
success_criteria:
|
||||
expected_success_value: { computed_steps_min: 2, stub_steps_marked: STUBBED }
|
||||
evidence_artifacts: [Temp/evidence/QE-M3-04/verdict.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M3-04"]
|
||||
evidence_checks:
|
||||
- type: log_pattern
|
||||
file_glob: src/dotnet/QuantEngine.Application/Services/PipelineOrchestrator.cs
|
||||
pattern: 'STUBBED'
|
||||
expect: { min_matches: 1 }
|
||||
|
||||
QE-M3-05:
|
||||
title: "스코어 FE (SS001 테이블 — factor_output_history 값과 DOM 대조)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M3-03, QE-M0-03]
|
||||
owner_files:
|
||||
- tests/e2e/evidence/qe-m3-05-scores.spec.ts
|
||||
success_criteria:
|
||||
expected_success_value: { spec_passed: 1 }
|
||||
evidence_artifacts: [Temp/evidence/QE-M3-05/verdict.json]
|
||||
verification_commands:
|
||||
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m3-05-scores.spec.ts"
|
||||
- "python tools/verify_wbs_task_v1.py --task QE-M3-05"
|
||||
evidence_checks:
|
||||
- type: playwright_report
|
||||
report: Temp/evidence/playwright-last-run.json
|
||||
spec_file: qe-m3-05-scores.spec.ts
|
||||
expect: { passed_min: 1, failed: 0 }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M4 — 백테스팅 + 검증
|
||||
# ---------------------------------------------------------------------------
|
||||
QE-M4-01:
|
||||
title: "백테스터 + 거래비용 모델 (Sharpe/MDD/턴오버/비용 드래그 JSON)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M3-03]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Core/Domain/Backtester.cs
|
||||
- src/dotnet/QuantEngine.Tools/
|
||||
success_criteria:
|
||||
expected_success_value: { metrics_populated: [sharpe, mdd, turnover, cost_drag] }
|
||||
evidence_artifacts: [Temp/evidence/QE-M4-01/verdict.json, Temp/backtest_result_v1.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-01"]
|
||||
evidence_checks:
|
||||
- type: json_gate
|
||||
path: Temp/backtest_result_v1.json
|
||||
expect: { gate: PASS }
|
||||
|
||||
QE-M4-02:
|
||||
title: "no-lookahead 게이트 실배선 (정상 PASS + 오염 픽스처 FAIL 양방향 검증)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M4-01]
|
||||
owner_files:
|
||||
- tools/validate_no_lookahead_bias_v1.py
|
||||
success_criteria:
|
||||
expected_success_value: { real_run: PASS, corrupted_fixture: FAIL }
|
||||
evidence_artifacts: [Temp/evidence/QE-M4-02/verdict.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-02"]
|
||||
evidence_checks:
|
||||
- type: json_gate
|
||||
path: Temp/no_lookahead_bias_v1.json
|
||||
expect: { gate: PASS }
|
||||
|
||||
QE-M4-03:
|
||||
title: "워크포워드 하네스 (24m train / 6m test 롤링, 윈도우 ≥4)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M4-01]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Tools/
|
||||
success_criteria:
|
||||
expected_success_value: { windows_min: 4, oos_metrics_nonnull: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M4-03/verdict.json, Temp/walk_forward_v1.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-03"]
|
||||
evidence_checks:
|
||||
- type: json_gate
|
||||
path: Temp/walk_forward_v1.json
|
||||
expect: { gate: PASS, windows: ">=4" }
|
||||
|
||||
QE-M4-04:
|
||||
title: "T+5/T+20 성과 원장 (prediction_accuracy 실표본 재계산, t5_sample≥30)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M2-03]
|
||||
owner_files:
|
||||
- tools/
|
||||
success_criteria:
|
||||
expected_success_value: { t5_sample_min: 30 }
|
||||
evidence_artifacts: [Temp/evidence/QE-M4-04/verdict.json, Temp/prediction_accuracy_harness_v2.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-04"]
|
||||
evidence_checks:
|
||||
- type: json_gate
|
||||
path: Temp/prediction_accuracy_harness_v2.json
|
||||
expect: { t5_sample: ">=30" }
|
||||
|
||||
QE-M4-05:
|
||||
title: "백테스트 결과 FE (에쿼티커브/Sharpe/MDD — backtest_result_v1.json 값과 DOM 대조)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M4-01, QE-M0-03]
|
||||
owner_files:
|
||||
- tests/e2e/evidence/qe-m4-05-backtest.spec.ts
|
||||
success_criteria:
|
||||
expected_success_value: { spec_passed: 1 }
|
||||
evidence_artifacts: [Temp/evidence/QE-M4-05/verdict.json]
|
||||
verification_commands:
|
||||
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m4-05-backtest.spec.ts"
|
||||
- "python tools/verify_wbs_task_v1.py --task QE-M4-05"
|
||||
evidence_checks:
|
||||
- type: playwright_report
|
||||
report: Temp/evidence/playwright-last-run.json
|
||||
spec_file: qe-m4-05-backtest.spec.ts
|
||||
expect: { passed_min: 1, failed: 0 }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M5 — 포트폴리오 구성 + 최신 기법
|
||||
# ---------------------------------------------------------------------------
|
||||
QE-M5-01:
|
||||
title: "레짐 감지기 (spec/11_market_regime.yaml — 실제 매크로 시계열, 전 거래일 라벨)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M2-03]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Core/Domain/
|
||||
success_criteria:
|
||||
expected_success_value: { regime_labels_full_window: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M5-01/verdict.json, Temp/market_regime_v1.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M5-01"]
|
||||
evidence_checks:
|
||||
- type: json_gate
|
||||
path: Temp/market_regime_v1.json
|
||||
expect: { gate: PASS }
|
||||
|
||||
QE-M5-02:
|
||||
title: "SS001 가중치 워크포워드 캘리브레이션 (±50% 제약 + shrinkage λ=0.5, 정직 보고)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M4-03, QE-M5-01]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Tools/
|
||||
notes: "게이트는 방법론 필드(제약 준수, OOS 비교 존재)를 검증 — 캘리브레이션이 '이겨야' PASS 가 아님"
|
||||
success_criteria:
|
||||
expected_success_value: { weights_within_bounds: true, oos_comparison_reported: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M5-02/verdict.json, Temp/weight_calibration_v1.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M5-02"]
|
||||
evidence_checks:
|
||||
- type: json_gate
|
||||
path: Temp/weight_calibration_v1.json
|
||||
expect: { gate: PASS }
|
||||
|
||||
QE-M5-03:
|
||||
title: "변동성 타게팅 사이징 + heat/집중도 캡 합성 → 최종 목표 포트폴리오 패킷"
|
||||
status: PENDING
|
||||
depends_on: [QE-M5-02]
|
||||
owner_files:
|
||||
- src/dotnet/QuantEngine.Application/Services/
|
||||
success_criteria:
|
||||
expected_success_value: { weights_sum_lte_100: true, all_caps_satisfied: true }
|
||||
evidence_artifacts: [Temp/evidence/QE-M5-03/verdict.json, Temp/target_portfolio_v1.json]
|
||||
verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M5-03"]
|
||||
evidence_checks:
|
||||
- type: json_gate
|
||||
path: Temp/target_portfolio_v1.json
|
||||
expect: { gate: PASS }
|
||||
|
||||
QE-M5-04:
|
||||
title: "포트폴리오·레짐 대시보드 FE (레짐 배지·목표 가중치 — API 값과 DOM 대조)"
|
||||
status: PENDING
|
||||
depends_on: [QE-M5-03, QE-M0-03]
|
||||
owner_files:
|
||||
- tests/e2e/evidence/qe-m5-04-portfolio.spec.ts
|
||||
success_criteria:
|
||||
expected_success_value: { spec_passed: 1 }
|
||||
evidence_artifacts: [Temp/evidence/QE-M5-04/verdict.json]
|
||||
verification_commands:
|
||||
- "npx playwright test --project=evidence tests/e2e/evidence/qe-m5-04-portfolio.spec.ts"
|
||||
- "python tools/verify_wbs_task_v1.py --task QE-M5-04"
|
||||
evidence_checks:
|
||||
- type: playwright_report
|
||||
report: Temp/evidence/playwright-last-run.json
|
||||
spec_file: qe-m5-04-portfolio.spec.ts
|
||||
expect: { passed_min: 1, failed: 0 }
|
||||
@@ -0,0 +1,19 @@
|
||||
formula_id: DOTNET_POSTGRESQL_JSON_CUTOVER_V1
|
||||
version: 1
|
||||
authority: spec/02_data_contract.yaml
|
||||
canonical_runtime:
|
||||
collector: src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs
|
||||
seed_ingestion: src/dotnet/QuantEngine.Application/Services/JsonSeedIngestionService.cs
|
||||
storage: src/dotnet/QuantEngine.Infrastructure/Repositories/CollectionRepository.cs
|
||||
migration: src/dotnet/QuantEngine.Infrastructure/Migrations/V5__Add_Normalized_Learning_History.sql
|
||||
output: Temp/kis_dotnet_collection_v1.json
|
||||
legacy_policy:
|
||||
python_collector: migration_only
|
||||
sqlite_store: migration_only
|
||||
xlsx_runtime_input: forbidden
|
||||
gates:
|
||||
- dotnet_collector_registered
|
||||
- postgresql_collection_repository_registered
|
||||
- json_seed_ingestion_registered
|
||||
- dbup_v5_embedded
|
||||
- xlsx_not_runtime_dependency
|
||||
@@ -0,0 +1,21 @@
|
||||
formula_id: DOTNET_FORMULA_CANONICAL_COVERAGE_V1
|
||||
version: 1
|
||||
authority:
|
||||
registry: spec/13b_harness_formulas.yaml
|
||||
lifecycle: spec/51_formula_lifecycle_registry.yaml
|
||||
implementation: src/dotnet/QuantEngine.Core/Domain/FormulaCanonicalCoverage.cs
|
||||
verification: src/dotnet/QuantEngine.Core.Tests/FormulaCanonicalCoverageTests.cs
|
||||
active_formula_ids:
|
||||
- ANTI_CHASE_V1
|
||||
- CASH_RECOVERY_V1
|
||||
- COMPREHENSIVE_PROPOSAL_V1
|
||||
- DFG_V1
|
||||
- INTRADAY_V1
|
||||
- PORTFOLIO_HEALTH_V1
|
||||
- RS_V2_FUSION
|
||||
- STOP_BREACH_V1
|
||||
- TICK_NORM_V1
|
||||
success_criteria:
|
||||
coverage_audit_true_missing_count: 0
|
||||
dotnet_build_errors: 0
|
||||
canonical_test_gate: PASS
|
||||
@@ -0,0 +1,12 @@
|
||||
formula_id: RUNTIME_CONNECTION_SETTINGS_IMMUTABILITY_V1
|
||||
version: 1
|
||||
authority: AGENTS.md
|
||||
setting_key: ConnectionStrings__DefaultConnection
|
||||
owner: operations
|
||||
policy:
|
||||
source: runtime_environment_or_external_settings
|
||||
application_may_read: true
|
||||
application_may_write: false
|
||||
secret_value_in_repository: allowed_only_when_explicitly_restoring_authoritative_git_value
|
||||
evidence: Temp/runtime_connection_settings_immutability_v1.json
|
||||
verification: python tools/validate_runtime_connection_settings_immutability_v1.py
|
||||
@@ -0,0 +1,10 @@
|
||||
formula_id: MARKET_TIME_SERIES_SCHEMA_V1
|
||||
version: 1
|
||||
authority: spec/60_quant_engine_wbs.yaml
|
||||
migration: src/dotnet/QuantEngine.Infrastructure/Migrations/V6__Add_Market_Time_Series.sql
|
||||
dbml: docs/db/quantengine.dbml
|
||||
tables:
|
||||
- quantengine.price_history_daily
|
||||
- quantengine.macro_history_daily
|
||||
runtime_database_status: DATA_GATED
|
||||
verification: python tools/validate_market_time_series_schema_v1.py
|
||||
@@ -4,6 +4,10 @@
|
||||
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Text.Json;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
|
||||
namespace QuantEngine.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical application path for recording factor evidence, decisions, and
|
||||
/// realized outcomes in the normalized PostgreSQL learning store.
|
||||
/// </summary>
|
||||
public sealed class DecisionLearningService
|
||||
{
|
||||
private readonly INormalizedLearningStore _store;
|
||||
|
||||
public DecisionLearningService(INormalizedLearningStore store) => _store = store;
|
||||
|
||||
public async Task<Guid> RecordDecisionAsync(
|
||||
string decisionKey,
|
||||
DateTimeOffset decidedAt,
|
||||
string instrumentId,
|
||||
string action,
|
||||
string gate,
|
||||
decimal? score,
|
||||
string sourceVersion,
|
||||
IEnumerable<FactorEvidenceInput> factors,
|
||||
object? trace = null,
|
||||
object? provenance = null)
|
||||
{
|
||||
var decisionId = await _store.AppendDecisionAsync(new DecisionEventRecord(
|
||||
decisionKey,
|
||||
decidedAt,
|
||||
instrumentId,
|
||||
action,
|
||||
gate,
|
||||
score,
|
||||
sourceVersion,
|
||||
JsonSerializer.Serialize(trace ?? new { }),
|
||||
JsonSerializer.Serialize(provenance ?? new { })));
|
||||
|
||||
foreach (var factor in factors)
|
||||
{
|
||||
var observationId = await _store.AppendSourceObservationAsync(new SourceObservationRecord(
|
||||
factor.ObservedAt,
|
||||
instrumentId,
|
||||
factor.SourceName,
|
||||
sourceVersion,
|
||||
factor.PayloadJson,
|
||||
factor.ProvenanceJson));
|
||||
var factorObservationId = await _store.AppendFactorObservationAsync(new FactorObservationRecord(
|
||||
observationId,
|
||||
factor.FactorObservationId,
|
||||
factor.FactorId,
|
||||
factor.FactorVersion,
|
||||
factor.ObservedAt,
|
||||
factor.NumericValue,
|
||||
factor.TextValue,
|
||||
factor.Gate,
|
||||
factor.ProvenanceJson));
|
||||
await _store.AppendDecisionFactorEvidenceAsync(decisionId, factorObservationId, factor.Role);
|
||||
}
|
||||
|
||||
return decisionId;
|
||||
}
|
||||
|
||||
public Task RecordOutcomeAsync(
|
||||
Guid decisionId,
|
||||
int horizonDays,
|
||||
DateTimeOffset evaluatedAt,
|
||||
decimal? realizedReturn,
|
||||
decimal? benchmarkReturn,
|
||||
string outcomeClass,
|
||||
string evaluationGate,
|
||||
object? provenance = null)
|
||||
{
|
||||
decimal? excessReturn = realizedReturn.HasValue && benchmarkReturn.HasValue
|
||||
? realizedReturn.Value - benchmarkReturn.Value
|
||||
: null;
|
||||
return _store.AppendOutcomeAsync(new OutcomeEvaluationRecord(
|
||||
decisionId,
|
||||
horizonDays,
|
||||
evaluatedAt,
|
||||
realizedReturn,
|
||||
benchmarkReturn,
|
||||
excessReturn,
|
||||
outcomeClass,
|
||||
evaluationGate,
|
||||
JsonSerializer.Serialize(provenance ?? new { })));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record FactorEvidenceInput(
|
||||
Guid FactorObservationId,
|
||||
string FactorId,
|
||||
string FactorVersion,
|
||||
DateTimeOffset ObservedAt,
|
||||
decimal? NumericValue,
|
||||
string? TextValue,
|
||||
string Gate,
|
||||
string Role,
|
||||
string SourceName,
|
||||
string PayloadJson,
|
||||
string ProvenanceJson);
|
||||
@@ -8,10 +8,12 @@ namespace QuantEngine.Application.Services
|
||||
public class FormulaService
|
||||
{
|
||||
private readonly IPostgresqlHistoryStore _historyStore;
|
||||
private readonly DecisionLearningService _learningService;
|
||||
|
||||
public FormulaService(IPostgresqlHistoryStore historyStore)
|
||||
public FormulaService(IPostgresqlHistoryStore historyStore, DecisionLearningService learningService)
|
||||
{
|
||||
_historyStore = historyStore;
|
||||
_learningService = learningService;
|
||||
}
|
||||
|
||||
public TimingDecisionResult ComputeTimingDecision(Dictionary<string, object> ctx)
|
||||
@@ -23,6 +25,27 @@ namespace QuantEngine.Application.Services
|
||||
public FinalDecisionResult ComputeFinalDecision(Dictionary<string, object> ctx)
|
||||
=> FormulaEngine.ComputeFinalDecision(ctx);
|
||||
|
||||
public async Task<Guid> ComputeAndRecordFinalDecisionAsync(
|
||||
Dictionary<string, object> ctx,
|
||||
string decisionKey,
|
||||
string instrumentId,
|
||||
string sourceVersion,
|
||||
IEnumerable<FactorEvidenceInput> factorEvidence)
|
||||
{
|
||||
var decision = ComputeFinalDecision(ctx);
|
||||
return await _learningService.RecordDecisionAsync(
|
||||
decisionKey,
|
||||
DateTimeOffset.UtcNow,
|
||||
instrumentId,
|
||||
decision.FinalAction,
|
||||
"PASS",
|
||||
Convert.ToDecimal(decision.PriorityScore),
|
||||
sourceVersion,
|
||||
factorEvidence,
|
||||
new { context_keys = ctx.Keys.OrderBy(key => key).ToArray() },
|
||||
new { formula = "FormulaEngine.ComputeFinalDecision", source_version = sourceVersion });
|
||||
}
|
||||
|
||||
public CashShortfallResult ComputeCashShortfallHarness(
|
||||
Dictionary<string, object> asResult,
|
||||
double totalAsset,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
|
||||
namespace QuantEngine.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// JSON-first seed ingestion path. XLSX conversion remains an external
|
||||
/// preparation step; the runtime application only reads canonical JSON and
|
||||
/// persists normalized snapshots to PostgreSQL.
|
||||
/// </summary>
|
||||
public sealed class JsonSeedIngestionService
|
||||
{
|
||||
private readonly GatherTradingDataParser _parser;
|
||||
private readonly ICollectionRepository _repository;
|
||||
private readonly ILogger<JsonSeedIngestionService> _logger;
|
||||
|
||||
public JsonSeedIngestionService(
|
||||
GatherTradingDataParser parser,
|
||||
ICollectionRepository repository,
|
||||
ILogger<JsonSeedIngestionService> logger)
|
||||
{
|
||||
_parser = parser;
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CollectionRunResult> IngestAsync(string jsonPath, string runId)
|
||||
{
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var rows = _parser.ParseGatherTradingData(jsonPath);
|
||||
await _repository.SaveRunAsync(new CollectionRunRecord(
|
||||
runId, "RUNNING", startedAt.ToString("O"), null, rows.Count, 0));
|
||||
|
||||
var errors = 0;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!row.TryGetValue("Ticker", out var tickerValue) || string.IsNullOrWhiteSpace(tickerValue?.ToString()))
|
||||
{
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var ticker = tickerValue.ToString()!;
|
||||
await _repository.SaveSnapshotAsync(new CollectionSnapshotRecord(
|
||||
runId,
|
||||
"data_feed",
|
||||
ticker,
|
||||
"json_seed",
|
||||
JsonSerializer.Serialize(row),
|
||||
startedAt.ToString("O")));
|
||||
}
|
||||
|
||||
var finishedAt = DateTimeOffset.UtcNow;
|
||||
var status = rows.Count > 0 && errors == 0 ? "COMPLETED" : "COMPLETED_WITH_ERRORS";
|
||||
await _repository.UpdateRunStatusAsync(runId, status, finishedAt.ToString("O"), rows.Count - errors, errors);
|
||||
_logger.LogInformation("JSON seed ingestion {RunId} completed: {Snapshots} snapshots, {Errors} errors", runId, rows.Count - errors, errors);
|
||||
|
||||
return new CollectionRunResult
|
||||
{
|
||||
RunId = runId,
|
||||
Status = status,
|
||||
StartedAt = startedAt.ToString("O"),
|
||||
FinishedAt = finishedAt.ToString("O"),
|
||||
SuccessCount = rows.Count - errors,
|
||||
ErrorCount = errors,
|
||||
Rows = rows
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using QuantEngine.Application.Services;
|
||||
@@ -13,19 +14,20 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
private readonly ICollectionRepository _repository;
|
||||
private readonly PriceDataNormalizer _normalizer;
|
||||
private readonly SourcePriorityResolver _priorityResolver;
|
||||
// Logging removed for simplicity
|
||||
private readonly ILogger<KisDataCollectionOrchestrator> _logger;
|
||||
|
||||
public KisDataCollectionOrchestrator(
|
||||
IKisApiClient kisApiClient,
|
||||
ICollectionRepository repository,
|
||||
PriceDataNormalizer normalizer,
|
||||
SourcePriorityResolver priorityResolver)
|
||||
SourcePriorityResolver priorityResolver,
|
||||
ILogger<KisDataCollectionOrchestrator> logger)
|
||||
{
|
||||
_kisApiClient = kisApiClient;
|
||||
_repository = repository;
|
||||
_normalizer = normalizer;
|
||||
_priorityResolver = priorityResolver;
|
||||
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CollectionRunResult> RunCollectionAsync(string runId, string account, List<string> tickers)
|
||||
@@ -42,7 +44,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
|
||||
try
|
||||
{
|
||||
// Log: skipped
|
||||
_logger.LogInformation("Starting collection run {RunId}", runId);
|
||||
|
||||
var kisSource = new KisApiPriceSource(_kisApiClient);
|
||||
var rows = new List<Dictionary<string, object>>();
|
||||
@@ -53,34 +55,61 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
{
|
||||
try
|
||||
{
|
||||
// Log: skipped
|
||||
var kisResult = await kisSource.GetPriceDataAsync(ticker, account);
|
||||
_logger.LogInformation("Collecting ticker {Ticker} (run {RunId})", ticker, runId);
|
||||
|
||||
var seedRow = new Dictionary<string, object> { { "Ticker", ticker } };
|
||||
var (normalized, provenance) = _normalizer.NormalizeCollectionRow(seedRow, kisResult, null, false);
|
||||
CollectionSnapshotRecord? cachedSnapshot = null;
|
||||
if (IsMarketClosed())
|
||||
{
|
||||
var latest = await _repository.GetLatestSnapshotsForTickerAsync(ticker, 1);
|
||||
var todayPrefix = DateTime.UtcNow.AddHours(9).ToString("yyyy-MM-dd");
|
||||
if (latest.Count > 0 && latest[0].CapturedAt.StartsWith(todayPrefix))
|
||||
{
|
||||
cachedSnapshot = latest[0];
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<string, object> normalized;
|
||||
string sourceName;
|
||||
|
||||
if (cachedSnapshot != null)
|
||||
{
|
||||
_logger.LogInformation("Cache hit for ticker {Ticker} (run {RunId})", ticker, runId);
|
||||
normalized = JsonSerializer.Deserialize<Dictionary<string, object>>(cachedSnapshot.PayloadJson)
|
||||
?? new Dictionary<string, object>();
|
||||
sourceName = cachedSnapshot.SourceName.EndsWith(" (Cached)")
|
||||
? cachedSnapshot.SourceName
|
||||
: cachedSnapshot.SourceName + " (Cached)";
|
||||
}
|
||||
else
|
||||
{
|
||||
var kisResult = await kisSource.GetPriceDataAsync(ticker, account);
|
||||
var seedRow = new Dictionary<string, object> { { "Ticker", ticker } };
|
||||
var (norm, provenance) = _normalizer.NormalizeCollectionRow(seedRow, kisResult, null, false);
|
||||
normalized = norm;
|
||||
sourceName = (string)(provenance.GetValueOrDefault("source") ?? "kis_open_api");
|
||||
}
|
||||
|
||||
// Save to DB
|
||||
await _repository.SaveSnapshotAsync(new CollectionSnapshotRecord(
|
||||
RunId: runId,
|
||||
DatasetName: "data_feed",
|
||||
Ticker: ticker,
|
||||
SourceName: (string)(provenance.GetValueOrDefault("source") ?? "kis_open_api"),
|
||||
SourceName: sourceName,
|
||||
PayloadJson: JsonSerializer.Serialize(normalized),
|
||||
CapturedAt: DataNormalizationHelper.KstNowIso()
|
||||
));
|
||||
|
||||
// Track source
|
||||
var source = (string)(provenance.GetValueOrDefault("source") ?? "kis_open_api");
|
||||
if (!sourceCounts.ContainsKey(source))
|
||||
sourceCounts[source] = 0;
|
||||
sourceCounts[source]++;
|
||||
if (!sourceCounts.ContainsKey(sourceName))
|
||||
sourceCounts[sourceName] = 0;
|
||||
sourceCounts[sourceName]++;
|
||||
|
||||
rows.Add(normalized);
|
||||
result.SuccessCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log: skipped
|
||||
_logger.LogWarning(ex, "Collection failed for {Ticker} (run {RunId})", ticker, runId);
|
||||
result.ErrorCount++;
|
||||
errors.Add(new Dictionary<string, object>
|
||||
{
|
||||
@@ -116,33 +145,122 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
TotalErrors: result.ErrorCount
|
||||
));
|
||||
|
||||
// Output JSON file
|
||||
var outputPath = Path.Combine(Path.GetTempPath(), "kis_data_collection_v1.json");
|
||||
// Determine gate status
|
||||
var gate = result.SuccessCount == 0 ? "FAIL"
|
||||
: result.ErrorCount == 0 ? "PASS"
|
||||
: result.ErrorCount < result.SuccessCount * 0.1 ? "PASS"
|
||||
: "PASS_WITH_WARNINGS";
|
||||
|
||||
// Output JSON file to <repo>/Temp/kis_dotnet_collection_v1.json
|
||||
var outputPath = GetOutputPath();
|
||||
var outputData = new
|
||||
{
|
||||
formula_id = "KIS_DATA_COLLECTION_V1",
|
||||
formula_id = "KIS_DOTNET_COLLECTION_V1",
|
||||
gate = gate,
|
||||
run_id = runId,
|
||||
started_at = startedAt,
|
||||
finished_at = finishedAt,
|
||||
row_count = rows.Count,
|
||||
source_counts = sourceCounts,
|
||||
errors = errors,
|
||||
rows = rows
|
||||
summary = new
|
||||
{
|
||||
success_count = result.SuccessCount,
|
||||
error_count = result.ErrorCount,
|
||||
source_counts = sourceCounts
|
||||
}
|
||||
};
|
||||
File.WriteAllText(outputPath, JsonSerializer.Serialize(outputData, new JsonSerializerOptions { WriteIndented = true }));
|
||||
// Log: skipped
|
||||
LogLineageEvent(runId, result.Status, result.SuccessCount, result.ErrorCount);
|
||||
|
||||
_logger.LogInformation("Collection run {RunId} finished with status {Status}: {Success} ok, {Errors} errors",
|
||||
runId, result.Status, result.SuccessCount, result.ErrorCount);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log: skipped
|
||||
_logger.LogError(ex, "Collection run {RunId} failed with exception", runId);
|
||||
result.Status = "FAILED";
|
||||
result.FinishedAt = DataNormalizationHelper.KstNowIso();
|
||||
result.ErrorMessage = ex.Message;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetOutputPath()
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var current = new DirectoryInfo(baseDir);
|
||||
|
||||
while (current != null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current.FullName, ".git"))
|
||||
|| File.Exists(Path.Combine(current.FullName, "GatherTradingData.json")))
|
||||
{
|
||||
var tempDir = Path.Combine(current.FullName, "Temp");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
return Path.Combine(tempDir, "kis_dotnet_collection_v1.json");
|
||||
}
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return Path.Combine(Path.GetTempPath(), "kis_dotnet_collection_v1.json");
|
||||
}
|
||||
|
||||
private static bool IsMarketClosed()
|
||||
{
|
||||
// KST Time conversion (UTC+9)
|
||||
var kst = DateTime.UtcNow.AddHours(9);
|
||||
|
||||
// Weekend check
|
||||
if (kst.DayOfWeek == DayOfWeek.Saturday || kst.DayOfWeek == DayOfWeek.Sunday)
|
||||
return true;
|
||||
|
||||
// Market hours check (09:00 - 15:30)
|
||||
var time = kst.TimeOfDay;
|
||||
if (time < new TimeSpan(9, 0, 0) || time > new TimeSpan(15, 30, 0))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void LogLineageEvent(string runId, string status, int successCount, int errorCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var current = new DirectoryInfo(baseDir);
|
||||
string? repoRoot = null;
|
||||
|
||||
while (current != null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
|
||||
{
|
||||
repoRoot = current.FullName;
|
||||
break;
|
||||
}
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
if (repoRoot != null)
|
||||
{
|
||||
var runtimeDir = Path.Combine(repoRoot, "runtime");
|
||||
Directory.CreateDirectory(runtimeDir);
|
||||
var lineagePath = Path.Combine(runtimeDir, "lineage_events.jsonl");
|
||||
|
||||
var ev = new
|
||||
{
|
||||
@event = "collection_run_completed",
|
||||
run_id = runId,
|
||||
status = status,
|
||||
success_count = successCount,
|
||||
error_count = errorCount,
|
||||
timestamp = DataNormalizationHelper.KstNowIso()
|
||||
};
|
||||
|
||||
File.AppendAllText(lineagePath, JsonSerializer.Serialize(ev) + "\n");
|
||||
}
|
||||
}
|
||||
catch { /* Robust fallback */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
|
||||
namespace QuantEngine.Application.Services;
|
||||
|
||||
public sealed class LearningDatasetService
|
||||
{
|
||||
private readonly ILearningDatasetReader _reader;
|
||||
|
||||
public LearningDatasetService(ILearningDatasetReader reader) => _reader = reader;
|
||||
|
||||
public async Task<string> ExportJsonAsync(string outputPath, int limit = 1000)
|
||||
{
|
||||
var rows = await _reader.ReadTrainingExamplesAsync(limit);
|
||||
var payload = new
|
||||
{
|
||||
formula_id = "ENGINE_HISTORY_TRAINING_DATASET_V1",
|
||||
gate = rows.Count > 0 ? "PASS" : "DATA_MISSING",
|
||||
generated_at = DateTimeOffset.UtcNow,
|
||||
sample_count = rows.Count,
|
||||
source = "engine_history.training_example_v1",
|
||||
rows
|
||||
};
|
||||
var path = Path.GetFullPath(outputPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,8 @@ namespace QuantEngine.Application.Services
|
||||
result.TotalElapsedMilliseconds = totalSw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// Output JSON file for integration validation
|
||||
var tempDir = @"C:\Temp\data_feed\Temp";
|
||||
var tempDir = Environment.GetEnvironmentVariable("QE_TEMP_ROOT")
|
||||
?? Path.Combine(Directory.GetCurrentDirectory(), "Temp");
|
||||
if (!Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema_version": "2026-05-24-operational-report-v1",
|
||||
"source_json": "GatherTradingData.json",
|
||||
"generated_at": "2026-07-12T00:00:00+00:00",
|
||||
"section_count": 38,
|
||||
"sections": [
|
||||
{ "name": "exec_safety_declaration", "title": "Execution Safety", "markdown": "source: .NET operational report builder" },
|
||||
{ "name": "portfolio_health", "title": "Portfolio Health", "markdown": "fixture" },
|
||||
{ "name": "factor_evidence", "title": "Factor Evidence", "markdown": "fixture" },
|
||||
{ "name": "decision_ledger", "title": "Decision Ledger", "markdown": "fixture" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using QuantEngine.Core.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace QuantEngine.Core.Tests;
|
||||
|
||||
public sealed class FormulaCanonicalCoverageTests
|
||||
{
|
||||
[Fact]
|
||||
public void ActiveFormulaImplementationsReturnTheirCanonicalIds()
|
||||
{
|
||||
var input = new Dictionary<string, object?>
|
||||
{
|
||||
["velocity_1d"] = 0.01d, ["velocity_threshold"] = 0.02d,
|
||||
["cash_shortfall_krw"] = 100d, ["recovered_krw"] = 100d,
|
||||
["proposal_gate"] = "PASS", ["cycle_detected"] = false,
|
||||
["intraday_restriction_gate"] = "PASS", ["portfolio_health_label"] = "HEALTHY",
|
||||
["rs_v2_score"] = 1d, ["technical_score"] = 1d,
|
||||
["current_price"] = 90d, ["stop_loss_price"] = 100d, ["gap_threshold"] = 0.05d,
|
||||
["price"] = 10_000d,
|
||||
};
|
||||
|
||||
var results = new[]
|
||||
{
|
||||
FormulaCanonicalCoverage.AntiChaseV1(input),
|
||||
FormulaCanonicalCoverage.CashRecoveryV1(input),
|
||||
FormulaCanonicalCoverage.ComprehensiveProposalV1(input),
|
||||
FormulaCanonicalCoverage.DfgV1(input),
|
||||
FormulaCanonicalCoverage.IntradayV1(input),
|
||||
FormulaCanonicalCoverage.PortfolioHealthV1(input),
|
||||
FormulaCanonicalCoverage.RsV2Fusion(input),
|
||||
FormulaCanonicalCoverage.StopBreachV1(input),
|
||||
FormulaCanonicalCoverage.TickNormV1(input),
|
||||
};
|
||||
|
||||
Assert.Equal(9, results.Length);
|
||||
Assert.All(results, result => Assert.NotEqual("DATA_MISSING — 하네스 업데이트 필요", result["gate"]));
|
||||
Assert.Equal(9, results.Select(result => result["formula_id"]).Distinct().Count());
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,9 @@ namespace QuantEngine.Core.Tests
|
||||
Assert.True(step.ElapsedMilliseconds > 0);
|
||||
}
|
||||
|
||||
var expectedJsonPath = @"C:\Temp\data_feed\Temp\dotnet_pipeline_e2e_v1.json";
|
||||
var tempRoot = Environment.GetEnvironmentVariable("QE_TEMP_ROOT")
|
||||
?? Path.Combine(Directory.GetCurrentDirectory(), "Temp");
|
||||
var expectedJsonPath = Path.Combine(tempRoot, "dotnet_pipeline_e2e_v1.json");
|
||||
Assert.True(File.Exists(expectedJsonPath));
|
||||
|
||||
var jsonContent = File.ReadAllText(expectedJsonPath);
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="Hangfire.Core" Version="1.8.23" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -22,6 +24,10 @@
|
||||
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
||||
<ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" />
|
||||
<ProjectReference Include="..\QuantEngine.Infrastructure\QuantEngine.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\QuantEngine.Web\QuantEngine.Web.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\operational_report.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Hangfire;
|
||||
using Hangfire.Common;
|
||||
using QuantEngine.Web.Services;
|
||||
|
||||
namespace QuantEngine.Core.Tests;
|
||||
|
||||
public class SchedulerServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void InitializeSchedules_RegistersAllFourRequiredJobs()
|
||||
{
|
||||
// Arrange
|
||||
var loggerMock = new Mock<ILogger<SchedulerService>>();
|
||||
var jobClientMock = new Mock<IBackgroundJobClient>();
|
||||
var recurringJobManagerMock = new Mock<IRecurringJobManager>();
|
||||
var scopeFactoryMock = new Mock<IServiceScopeFactory>();
|
||||
|
||||
var configMock = new Mock<IConfiguration>();
|
||||
configMock.Setup(c => c["Kis:AccountMode"]).Returns("mock");
|
||||
|
||||
var service = new SchedulerService(
|
||||
loggerMock.Object,
|
||||
jobClientMock.Object,
|
||||
recurringJobManagerMock.Object,
|
||||
scopeFactoryMock.Object,
|
||||
configMock.Object
|
||||
);
|
||||
|
||||
// Act
|
||||
service.InitializeSchedules();
|
||||
|
||||
// Assert
|
||||
// Verify daily-collection was added or updated
|
||||
recurringJobManagerMock.Verify(m => m.AddOrUpdate(
|
||||
"daily-collection",
|
||||
It.IsAny<Job>(),
|
||||
"0 9 * * *",
|
||||
It.IsAny<RecurringJobOptions>()
|
||||
), Times.Once);
|
||||
|
||||
// Verify hourly-price-update was added or updated
|
||||
recurringJobManagerMock.Verify(m => m.AddOrUpdate(
|
||||
"hourly-price-update",
|
||||
It.IsAny<Job>(),
|
||||
"0 9,11,13,15 * * 1-5",
|
||||
It.IsAny<RecurringJobOptions>()
|
||||
), Times.Once);
|
||||
|
||||
// Verify weekly-report was added or updated
|
||||
recurringJobManagerMock.Verify(m => m.AddOrUpdate(
|
||||
"weekly-report",
|
||||
It.IsAny<Job>(),
|
||||
"0 17 * * 5",
|
||||
It.IsAny<RecurringJobOptions>()
|
||||
), Times.Once);
|
||||
|
||||
// Verify monthly-optimization was added or updated
|
||||
recurringJobManagerMock.Verify(m => m.AddOrUpdate(
|
||||
"monthly-optimization",
|
||||
It.IsAny<Job>(),
|
||||
"0 2 1 * *",
|
||||
It.IsAny<RecurringJobOptions>()
|
||||
), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ public class UnitTest1
|
||||
[Fact]
|
||||
public void OperationalReportLoader_ParsesCanonicalTempReport()
|
||||
{
|
||||
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "Temp", "operational_report.json"));
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "operational_report.json");
|
||||
var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path);
|
||||
|
||||
Assert.Equal("2026-05-24-operational-report-v1", report.SchemaVersion);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace QuantEngine.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical .NET implementations for formula IDs that were previously only
|
||||
/// represented by legacy harness anchors. Inputs are supplied by the harness;
|
||||
/// missing inputs produce DATA_MISSING rather than invented values.
|
||||
/// </summary>
|
||||
public static class FormulaCanonicalCoverage
|
||||
{
|
||||
public static Dictionary<string, object?> AntiChaseV1(IReadOnlyDictionary<string, object?> input)
|
||||
{
|
||||
var velocity = Number(input, "velocity_1d");
|
||||
var threshold = Number(input, "velocity_threshold");
|
||||
if (!velocity.HasValue || !threshold.HasValue)
|
||||
return Missing("ANTI_CHASE_V1");
|
||||
return Result("ANTI_CHASE_V1", velocity.Value > threshold.Value ? "BLOCK" : "PASS", velocity.Value);
|
||||
}
|
||||
|
||||
public static Dictionary<string, object?> CashRecoveryV1(IReadOnlyDictionary<string, object?> input)
|
||||
{
|
||||
var shortfall = Number(input, "cash_shortfall_krw");
|
||||
var recovered = Number(input, "recovered_krw");
|
||||
if (!shortfall.HasValue || !recovered.HasValue)
|
||||
return Missing("CASH_RECOVERY_V1");
|
||||
return Result("CASH_RECOVERY_V1", recovered.Value >= shortfall.Value ? "PASS" : "LIMITED", recovered.Value);
|
||||
}
|
||||
|
||||
public static Dictionary<string, object?> ComprehensiveProposalV1(IReadOnlyDictionary<string, object?> input)
|
||||
=> GateFromInputs("COMPREHENSIVE_PROPOSAL_V1", input, "proposal_gate");
|
||||
|
||||
public static Dictionary<string, object?> DfgV1(IReadOnlyDictionary<string, object?> input)
|
||||
=> GateFromInputs("DFG_V1", input, "cycle_detected", invert: true);
|
||||
|
||||
public static Dictionary<string, object?> IntradayV1(IReadOnlyDictionary<string, object?> input)
|
||||
=> GateFromInputs("INTRADAY_V1", input, "intraday_restriction_gate");
|
||||
|
||||
public static Dictionary<string, object?> PortfolioHealthV1(IReadOnlyDictionary<string, object?> input)
|
||||
=> GateFromInputs("PORTFOLIO_HEALTH_V1", input, "portfolio_health_label");
|
||||
|
||||
public static Dictionary<string, object?> RsV2Fusion(IReadOnlyDictionary<string, object?> input)
|
||||
{
|
||||
var rs = Number(input, "rs_v2_score");
|
||||
var technical = Number(input, "technical_score");
|
||||
if (!rs.HasValue || !technical.HasValue)
|
||||
return Missing("RS_V2_FUSION");
|
||||
var score = (rs.Value + technical.Value) / 2d;
|
||||
return Result("RS_V2_FUSION", score >= 0 ? "PASS" : "BLOCK", score);
|
||||
}
|
||||
|
||||
public static Dictionary<string, object?> StopBreachV1(IReadOnlyDictionary<string, object?> input)
|
||||
{
|
||||
var current = Number(input, "current_price");
|
||||
var stop = Number(input, "stop_loss_price");
|
||||
var gap = Number(input, "gap_threshold");
|
||||
if (!current.HasValue || !stop.HasValue || !gap.HasValue || stop.Value == 0)
|
||||
return Missing("STOP_BREACH_V1");
|
||||
var gapPct = (stop.Value - current.Value) / stop.Value;
|
||||
return Result("STOP_BREACH_V1", gapPct >= gap.Value ? "BREACH_IMMEDIATE_EXIT" : "PASS", gapPct);
|
||||
}
|
||||
|
||||
public static Dictionary<string, object?> TickNormV1(IReadOnlyDictionary<string, object?> input)
|
||||
{
|
||||
var price = Number(input, "price");
|
||||
if (!price.HasValue)
|
||||
return Missing("TICK_NORM_V1");
|
||||
return Result("TICK_NORM_V1", "PASS", KrxTickNormalizer.NormalizeTick(price.Value));
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> GateFromInputs(string id, IReadOnlyDictionary<string, object?> input, string field, bool invert = false)
|
||||
{
|
||||
if (!input.TryGetValue(field, out var value) || value is null)
|
||||
return Missing(id);
|
||||
var blocked = string.Equals(value.ToString(), "BLOCK", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value.ToString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (invert) blocked = !blocked;
|
||||
return Result(id, blocked ? "BLOCK" : "PASS", null);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> Missing(string id) => new()
|
||||
{
|
||||
["formula_id"] = id,
|
||||
["gate"] = "DATA_MISSING — 하네스 업데이트 필요",
|
||||
["value"] = null,
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> Result(string id, string gate, double? value) => new()
|
||||
{
|
||||
["formula_id"] = id,
|
||||
["gate"] = gate,
|
||||
["value"] = value,
|
||||
};
|
||||
|
||||
private static double? Number(IReadOnlyDictionary<string, object?> input, string key)
|
||||
{
|
||||
if (!input.TryGetValue(key, out var value) || value is null)
|
||||
return null;
|
||||
return double.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace QuantEngine.Core.Interfaces;
|
||||
|
||||
public interface ILearningDatasetReader
|
||||
{
|
||||
Task<IReadOnlyList<IDictionary<string, object?>>> ReadTrainingExamplesAsync(int limit = 1000);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace QuantEngine.Core.Interfaces;
|
||||
|
||||
public interface INormalizedLearningStore
|
||||
{
|
||||
Task<Guid> AppendSourceObservationAsync(SourceObservationRecord record);
|
||||
Task<Guid> AppendFactorObservationAsync(FactorObservationRecord record);
|
||||
Task<Guid> AppendDecisionAsync(DecisionEventRecord record);
|
||||
Task AppendDecisionFactorEvidenceAsync(Guid decisionId, Guid factorObservationId, string role);
|
||||
Task AppendOutcomeAsync(OutcomeEvaluationRecord record);
|
||||
}
|
||||
|
||||
public sealed record SourceObservationRecord(
|
||||
DateTimeOffset ObservedAt,
|
||||
string InstrumentId,
|
||||
string SourceName,
|
||||
string SourceVersion,
|
||||
string PayloadJson,
|
||||
string ProvenanceJson);
|
||||
|
||||
public sealed record FactorObservationRecord(
|
||||
Guid ObservationId,
|
||||
Guid FactorObservationId,
|
||||
string FactorId,
|
||||
string FactorVersion,
|
||||
DateTimeOffset ObservedAt,
|
||||
decimal? NumericValue,
|
||||
string? TextValue,
|
||||
string Gate,
|
||||
string ProvenanceJson);
|
||||
|
||||
public sealed record DecisionEventRecord(
|
||||
string DecisionKey,
|
||||
DateTimeOffset DecidedAt,
|
||||
string InstrumentId,
|
||||
string Action,
|
||||
string Gate,
|
||||
decimal? Score,
|
||||
string SourceVersion,
|
||||
string TraceJson,
|
||||
string ProvenanceJson);
|
||||
|
||||
public sealed record OutcomeEvaluationRecord(
|
||||
Guid DecisionId,
|
||||
int HorizonDays,
|
||||
DateTimeOffset EvaluatedAt,
|
||||
decimal? RealizedReturn,
|
||||
decimal? BenchmarkReturn,
|
||||
decimal? ExcessReturn,
|
||||
string OutcomeClass,
|
||||
string EvaluationGate,
|
||||
string ProvenanceJson);
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
-- V5__Add_Normalized_Learning_History.sql
|
||||
-- Normalized PostgreSQL event store for factor decisions and learning data.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE SCHEMA IF NOT EXISTS engine_history;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS engine_history.source_observation (
|
||||
observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
instrument_id TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
source_version TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_source_observation_instrument_time
|
||||
ON engine_history.source_observation (instrument_id, observed_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS engine_history.factor_definition (
|
||||
factor_id TEXT NOT NULL,
|
||||
factor_version TEXT NOT NULL,
|
||||
formula_id TEXT NOT NULL,
|
||||
effective_from TIMESTAMPTZ NOT NULL,
|
||||
effective_to TIMESTAMPTZ,
|
||||
definition JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (factor_id, factor_version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS engine_history.factor_observation (
|
||||
factor_observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
observation_id UUID NOT NULL REFERENCES engine_history.source_observation(observation_id),
|
||||
factor_id TEXT NOT NULL,
|
||||
factor_version TEXT NOT NULL,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
numeric_value NUMERIC,
|
||||
text_value TEXT,
|
||||
gate TEXT NOT NULL,
|
||||
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
CONSTRAINT fk_factor_definition
|
||||
FOREIGN KEY (factor_id, factor_version)
|
||||
REFERENCES engine_history.factor_definition(factor_id, factor_version),
|
||||
CONSTRAINT factor_value_present CHECK (numeric_value IS NOT NULL OR text_value IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_factor_observation_factor_time
|
||||
ON engine_history.factor_observation (factor_id, observed_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS engine_history.decision_event (
|
||||
decision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
decision_key TEXT NOT NULL UNIQUE,
|
||||
decided_at TIMESTAMPTZ NOT NULL,
|
||||
instrument_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
gate TEXT NOT NULL,
|
||||
score NUMERIC,
|
||||
source_version TEXT NOT NULL,
|
||||
trace JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_decision_event_instrument_time
|
||||
ON engine_history.decision_event (instrument_id, decided_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS engine_history.decision_factor_evidence (
|
||||
decision_id UUID NOT NULL REFERENCES engine_history.decision_event(decision_id),
|
||||
factor_observation_id UUID NOT NULL REFERENCES engine_history.factor_observation(factor_observation_id),
|
||||
role TEXT NOT NULL,
|
||||
PRIMARY KEY (decision_id, factor_observation_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS engine_history.outcome_evaluation (
|
||||
evaluation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
decision_id UUID NOT NULL REFERENCES engine_history.decision_event(decision_id),
|
||||
horizon_days INTEGER NOT NULL CHECK (horizon_days > 0),
|
||||
evaluated_at TIMESTAMPTZ NOT NULL,
|
||||
realized_return NUMERIC,
|
||||
benchmark_return NUMERIC,
|
||||
excess_return NUMERIC,
|
||||
outcome_class TEXT NOT NULL,
|
||||
evaluation_gate TEXT NOT NULL,
|
||||
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (decision_id, horizon_days)
|
||||
);
|
||||
|
||||
-- Read-optimized projection for model training and calibration jobs.
|
||||
CREATE OR REPLACE VIEW engine_history.training_example_v1 AS
|
||||
SELECT
|
||||
d.decision_id,
|
||||
d.decision_key,
|
||||
d.decided_at,
|
||||
d.instrument_id,
|
||||
d.action,
|
||||
d.gate AS decision_gate,
|
||||
d.score,
|
||||
d.source_version,
|
||||
e.horizon_days,
|
||||
e.realized_return,
|
||||
e.benchmark_return,
|
||||
e.excess_return,
|
||||
e.outcome_class,
|
||||
e.evaluation_gate,
|
||||
jsonb_agg(jsonb_build_object(
|
||||
'factor_id', f.factor_id,
|
||||
'factor_version', f.factor_version,
|
||||
'numeric_value', f.numeric_value,
|
||||
'text_value', f.text_value,
|
||||
'gate', f.gate,
|
||||
'role', evidence.role
|
||||
) ORDER BY f.factor_id) AS factor_features
|
||||
FROM engine_history.decision_event d
|
||||
JOIN engine_history.outcome_evaluation e ON e.decision_id = d.decision_id
|
||||
JOIN engine_history.decision_factor_evidence evidence ON evidence.decision_id = d.decision_id
|
||||
JOIN engine_history.factor_observation f ON f.factor_observation_id = evidence.factor_observation_id
|
||||
GROUP BY d.decision_id, d.decision_key, d.decided_at, d.instrument_id, d.action,
|
||||
d.gate, d.score, d.source_version, e.horizon_days, e.realized_return,
|
||||
e.benchmark_return, e.excess_return, e.outcome_class, e.evaluation_gate;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- V6__Add_Market_Time_Series.sql
|
||||
-- Canonical PostgreSQL daily series for point-in-time factor calculations.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS quantengine;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quantengine.price_history_daily (
|
||||
ticker TEXT NOT NULL,
|
||||
trade_date DATE NOT NULL,
|
||||
open NUMERIC NOT NULL,
|
||||
high NUMERIC NOT NULL,
|
||||
low NUMERIC NOT NULL,
|
||||
close NUMERIC NOT NULL,
|
||||
volume BIGINT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (ticker, trade_date),
|
||||
CONSTRAINT price_history_daily_ohlc_order CHECK (high >= low AND high >= open AND high >= close AND low <= open AND low <= close),
|
||||
CONSTRAINT price_history_daily_volume_nonnegative CHECK (volume >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_price_history_daily_date
|
||||
ON quantengine.price_history_daily (trade_date DESC, ticker);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quantengine.macro_history_daily (
|
||||
symbol TEXT NOT NULL,
|
||||
trade_date DATE NOT NULL,
|
||||
value NUMERIC NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (symbol, trade_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_macro_history_daily_date
|
||||
ON quantengine.macro_history_daily (trade_date DESC, symbol);
|
||||
@@ -0,0 +1,24 @@
|
||||
using Dapper;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
|
||||
namespace QuantEngine.Infrastructure.Repositories;
|
||||
|
||||
public sealed class LearningDatasetReader : ILearningDatasetReader
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
public LearningDatasetReader(IDbConnectionFactory connectionFactory) => _connectionFactory = connectionFactory;
|
||||
|
||||
public async Task<IReadOnlyList<IDictionary<string, object?>>> ReadTrainingExamplesAsync(int limit = 1000)
|
||||
{
|
||||
if (limit is < 1 or > 10000)
|
||||
throw new ArgumentOutOfRangeException(nameof(limit));
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync(
|
||||
"SELECT * FROM engine_history.training_example_v1 ORDER BY decided_at DESC LIMIT @Limit",
|
||||
new { Limit = limit });
|
||||
return rows.Select(row => (IDictionary<string, object?>)row).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Dapper;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
|
||||
namespace QuantEngine.Infrastructure.Repositories;
|
||||
|
||||
public sealed class NormalizedLearningStore : INormalizedLearningStore
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
public NormalizedLearningStore(IDbConnectionFactory connectionFactory) => _connectionFactory = connectionFactory;
|
||||
|
||||
public async Task<Guid> AppendSourceObservationAsync(SourceObservationRecord record)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(@"INSERT INTO engine_history.source_observation
|
||||
(observation_id, observed_at, instrument_id, source_name, source_version, payload, provenance)
|
||||
VALUES (@Id, @ObservedAt, @InstrumentId, @SourceName, @SourceVersion,
|
||||
CAST(@PayloadJson AS jsonb), CAST(@ProvenanceJson AS jsonb))", new { Id = id, record.ObservedAt, record.InstrumentId, record.SourceName, record.SourceVersion, record.PayloadJson, record.ProvenanceJson });
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task<Guid> AppendFactorObservationAsync(FactorObservationRecord record)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(@"INSERT INTO engine_history.factor_observation
|
||||
(factor_observation_id, observation_id, factor_id, factor_version, observed_at,
|
||||
numeric_value, text_value, gate, provenance)
|
||||
VALUES (@FactorObservationId, @ObservationId, @FactorId, @FactorVersion, @ObservedAt,
|
||||
@NumericValue, @TextValue, @Gate, CAST(@ProvenanceJson AS jsonb))", new
|
||||
{
|
||||
record.FactorObservationId,
|
||||
record.ObservationId,
|
||||
record.FactorId,
|
||||
record.FactorVersion,
|
||||
record.ObservedAt,
|
||||
record.NumericValue,
|
||||
record.TextValue,
|
||||
record.Gate,
|
||||
record.ProvenanceJson
|
||||
});
|
||||
return record.FactorObservationId;
|
||||
}
|
||||
|
||||
public async Task<Guid> AppendDecisionAsync(DecisionEventRecord record)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(@"INSERT INTO engine_history.decision_event
|
||||
(decision_id, decision_key, decided_at, instrument_id, action, gate, score,
|
||||
source_version, trace, provenance)
|
||||
VALUES (@Id, @DecisionKey, @DecidedAt, @InstrumentId, @Action, @Gate, @Score,
|
||||
@SourceVersion, CAST(@TraceJson AS jsonb), CAST(@ProvenanceJson AS jsonb))", new { Id = id, record.DecisionKey, record.DecidedAt, record.InstrumentId, record.Action, record.Gate, record.Score, record.SourceVersion, record.TraceJson, record.ProvenanceJson });
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task AppendDecisionFactorEvidenceAsync(Guid decisionId, Guid factorObservationId, string role)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(@"INSERT INTO engine_history.decision_factor_evidence
|
||||
(decision_id, factor_observation_id, role) VALUES (@DecisionId, @FactorObservationId, @Role)", new { decisionId, factorObservationId, role });
|
||||
}
|
||||
|
||||
public async Task AppendOutcomeAsync(OutcomeEvaluationRecord record)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(@"INSERT INTO engine_history.outcome_evaluation
|
||||
(decision_id, horizon_days, evaluated_at, realized_return, benchmark_return,
|
||||
excess_return, outcome_class, evaluation_gate, provenance)
|
||||
VALUES (@DecisionId, @HorizonDays, @EvaluatedAt, @RealizedReturn, @BenchmarkReturn,
|
||||
@ExcessReturn, @OutcomeClass, @EvaluationGate, CAST(@ProvenanceJson AS jsonb))
|
||||
ON CONFLICT (decision_id, horizon_days) DO UPDATE SET
|
||||
evaluated_at = EXCLUDED.evaluated_at,
|
||||
realized_return = EXCLUDED.realized_return,
|
||||
benchmark_return = EXCLUDED.benchmark_return,
|
||||
excess_return = EXCLUDED.excess_return,
|
||||
outcome_class = EXCLUDED.outcome_class,
|
||||
evaluation_gate = EXCLUDED.evaluation_gate,
|
||||
provenance = EXCLUDED.provenance", record);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
@@ -29,6 +31,7 @@ public class KisApiClient : IKisApiClient
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ITokenCache _tokenCache;
|
||||
private readonly ILogger<KisApiClient> _logger;
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> TokenLocks = new();
|
||||
|
||||
public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger<KisApiClient> logger)
|
||||
{
|
||||
@@ -138,61 +141,102 @@ public class KisApiClient : IKisApiClient
|
||||
if (!string.IsNullOrEmpty(queryString))
|
||||
url += $"?{queryString}";
|
||||
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
foreach (var header in headers)
|
||||
request.Headers.Add(header.Key, header.Value);
|
||||
int maxAttempts = 3;
|
||||
int delayMs = 1000;
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||
return result ?? new Dictionary<string, object>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
_logger.LogError(ex, "KIS request failed: {Path} / {TrId}", path, trId);
|
||||
throw new InvalidOperationException($"KIS read-only request failed for {path} / {trId}.", ex);
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
foreach (var header in headers)
|
||||
request.Headers.Add(header.Key, header.Value);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||
return result ?? new Dictionary<string, object>();
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
_logger.LogWarning(ex, "KIS request failed on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}ms...", attempt, maxAttempts, delayMs);
|
||||
await Task.Delay(delayMs);
|
||||
delayMs *= 2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "KIS request failed after {MaxAttempts} attempts: {Path} / {TrId}", maxAttempts, path, trId);
|
||||
throw new InvalidOperationException($"KIS read-only request failed for {path} / {trId} after {maxAttempts} attempts.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unreachable code in KIS client SendRequestAsync");
|
||||
}
|
||||
|
||||
private async Task<string> GetOrRefreshTokenAsync(KisCredentials creds)
|
||||
{
|
||||
var cachedToken = await _tokenCache.GetCachedTokenAsync(creds.Account);
|
||||
if (!string.IsNullOrEmpty(cachedToken))
|
||||
return cachedToken;
|
||||
|
||||
var tokenRequest = new { grant_type = "client_credentials", appkey = creds.AppKey, appsecret = creds.AppSecret };
|
||||
|
||||
var tokenLock = TokenLocks.GetOrAdd(creds.Account, _ => new SemaphoreSlim(1, 1));
|
||||
await tokenLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
$"{creds.Domain}/oauth2/tokenP",
|
||||
tokenRequest
|
||||
);
|
||||
response.EnsureSuccessStatusCode();
|
||||
// Re-check after acquiring the account lock. Another request may
|
||||
// have refreshed the shared cache while this request was waiting.
|
||||
var cachedToken = await _tokenCache.GetCachedTokenAsync(creds.Account);
|
||||
if (!string.IsNullOrEmpty(cachedToken))
|
||||
return cachedToken;
|
||||
|
||||
var tokenData = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||
if (tokenData == null) throw new InvalidOperationException("Token response body is empty");
|
||||
var tokenRequest = new { grant_type = "client_credentials", appkey = creds.AppKey, appsecret = creds.AppSecret };
|
||||
|
||||
if (!tokenData.TryGetValue("access_token", out var tokenObj) || tokenObj == null)
|
||||
throw new InvalidOperationException("No access_token in response");
|
||||
var accessToken = tokenObj.ToString()!;
|
||||
int maxAttempts = 3;
|
||||
int delayMs = 1000;
|
||||
|
||||
var expiresInStr = tokenData.TryGetValue("expires_in", out var expiresObj) && expiresObj != null
|
||||
? expiresObj.ToString()
|
||||
: "86400";
|
||||
var expiresInSec = int.TryParse(expiresInStr, out var seconds) ? seconds : 86400;
|
||||
var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec);
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
$"{creds.Domain}/oauth2/tokenP",
|
||||
tokenRequest
|
||||
);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await _tokenCache.SaveTokenAsync(creds.Account, accessToken, expiresAt);
|
||||
return accessToken;
|
||||
var tokenData = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||
if (tokenData == null) throw new InvalidOperationException("Token response body is empty");
|
||||
|
||||
if (!tokenData.TryGetValue("access_token", out var tokenObj) || tokenObj == null)
|
||||
throw new InvalidOperationException("No access_token in response");
|
||||
var accessToken = tokenObj.ToString()!;
|
||||
|
||||
var expiresInStr = tokenData.TryGetValue("expires_in", out var expiresObj) && expiresObj != null
|
||||
? expiresObj.ToString()
|
||||
: "86400";
|
||||
var expiresInSec = int.TryParse(expiresInStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)
|
||||
? seconds
|
||||
: 86400;
|
||||
var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec);
|
||||
|
||||
await _tokenCache.SaveTokenAsync(creds.Account, accessToken, expiresAt);
|
||||
_logger.LogInformation("KIS token refreshed for {Account}; expires at {ExpiresAtUtc}", creds.Account, expiresAt);
|
||||
return accessToken;
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
_logger.LogWarning(ex, "KIS token refresh failed on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}ms...", attempt, maxAttempts, delayMs);
|
||||
await Task.Delay(delayMs);
|
||||
delayMs *= 2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "KIS token refresh failed after {MaxAttempts} attempts", maxAttempts);
|
||||
throw new InvalidOperationException($"KIS token refresh failed after {maxAttempts} attempts; check credentials and API availability.", ex);
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException("Unreachable code in KIS client TokenRefresh");
|
||||
}
|
||||
catch (Exception ex)
|
||||
finally
|
||||
{
|
||||
_logger.LogError(ex, "KIS token refresh failed");
|
||||
throw new InvalidOperationException("KIS token refresh failed; check credentials and API availability.", ex);
|
||||
tokenLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
@@ -31,7 +32,11 @@ namespace QuantEngine.Infrastructure.Services
|
||||
if (token == null)
|
||||
return null;
|
||||
|
||||
var expiresAt = DateTime.Parse(token.ExpiresAt);
|
||||
DateTime expiresAt;
|
||||
if (!DateTime.TryParse((string)token.ExpiresAt, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out expiresAt))
|
||||
return null;
|
||||
var now = DateTime.UtcNow;
|
||||
var refreshSkew = TimeSpan.FromMinutes(TokenRefreshSkewMinutes);
|
||||
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
using Bunit;
|
||||
using MudBlazor;
|
||||
using Xunit;
|
||||
using QuantEngine.Web.Client.Pages;
|
||||
using QuantEngine.Web.Client.Components;
|
||||
|
||||
namespace QuantEngine.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for Dashboard component using bUnit
|
||||
/// </summary>
|
||||
public class DashboardComponentTests : TestContext
|
||||
{
|
||||
[Fact]
|
||||
public void Dashboard_Renders_Without_Errors()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Dashboard>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("관리자 대시보드");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dashboard_Displays_KPI_Cards()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Dashboard>();
|
||||
|
||||
// Assert - Should have 4 KPI cards
|
||||
cut.FindAll(".mud-paper").Count.Should().BeGreaterThanOrEqualTo(4);
|
||||
cut.Markup.Should().Contain("총 수집 실행");
|
||||
cut.Markup.Should().Contain("성공률");
|
||||
cut.Markup.Should().Contain("최근 에러");
|
||||
cut.Markup.Should().Contain("마지막 동기화");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dashboard_Shows_System_Status()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Dashboard>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("시스템 상태");
|
||||
cut.Markup.Should().Contain("API 서버");
|
||||
cut.Markup.Should().Contain("데이터베이스");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dashboard_Has_Activity_Feed()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Dashboard>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("최근 활동");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dashboard_Has_Collections_Table()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Dashboard>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("최근 데이터 수집 실행");
|
||||
cut.Markup.Should().Contain("새로고침");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for FormField component
|
||||
/// </summary>
|
||||
public class FormFieldComponentTests : TestContext
|
||||
{
|
||||
[Fact]
|
||||
public void FormField_Renders_Text_Input()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new ComponentParameterCollection
|
||||
{
|
||||
{ "Label", "사용자명" },
|
||||
{ "Type", "text" },
|
||||
{ "Placeholder", "이름 입력" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var cut = RenderComponent<FormField>(parameters);
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("사용자명");
|
||||
cut.Markup.Should().Contain("이름 입력");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormField_Shows_Required_Indicator()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new ComponentParameterCollection
|
||||
{
|
||||
{ "Label", "이메일" },
|
||||
{ "Type", "email" },
|
||||
{ "Required", true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var cut = RenderComponent<FormField>(parameters);
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormField_Displays_Error_Message()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new ComponentParameterCollection
|
||||
{
|
||||
{ "Label", "비밀번호" },
|
||||
{ "Type", "password" },
|
||||
{ "ErrorMessage", "최소 8자 이상 입력하세요" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var cut = RenderComponent<FormField>(parameters);
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("최소 8자 이상 입력하세요");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormField_Shows_Help_Text()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new ComponentParameterCollection
|
||||
{
|
||||
{ "Label", "핸드폰" },
|
||||
{ "Type", "tel" },
|
||||
{ "HelpText", "하이픈 없이 숫자만 입력하세요" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var cut = RenderComponent<FormField>(parameters);
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("하이픈 없이 숫자만 입력하세요");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for Portfolio component
|
||||
/// </summary>
|
||||
public class PortfolioComponentTests : TestContext
|
||||
{
|
||||
[Fact]
|
||||
public void Portfolio_Renders_Without_Errors()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Portfolio>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("포트폴리오");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Portfolio_Displays_Summary_Cards()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Portfolio>();
|
||||
|
||||
// Assert - Should have summary cards
|
||||
cut.Markup.Should().Contain("총 평가액");
|
||||
cut.Markup.Should().Contain("보유 종목");
|
||||
cut.Markup.Should().Contain("수익률");
|
||||
cut.Markup.Should().Contain("위험도");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Portfolio_Shows_Asset_Table()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Portfolio>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("자산 구성");
|
||||
cut.Markup.Should().Contain("종목/펀드명");
|
||||
cut.Markup.Should().Contain("평가액");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Portfolio_Shows_Asset_Classification()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Portfolio>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("자산 분류");
|
||||
cut.Markup.Should().Contain("대형주");
|
||||
cut.Markup.Should().Contain("중형주");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Portfolio_Shows_Trading_History()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Portfolio>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("거래 이력");
|
||||
cut.Markup.Should().Contain("구분");
|
||||
cut.Markup.Should().Contain("금액");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for NavMenu component
|
||||
/// </summary>
|
||||
public class NavMenuComponentTests : TestContext
|
||||
{
|
||||
[Fact]
|
||||
public void NavMenu_Renders_Navigation_Links()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<NavMenu>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("대시보드");
|
||||
cut.Markup.Should().Contain("관리");
|
||||
cut.Markup.Should().Contain("운영");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavMenu_Has_Admin_Section()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<NavMenu>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("사용자 관리");
|
||||
cut.Markup.Should().Contain("데이터 수집");
|
||||
cut.Markup.Should().Contain("설정");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavMenu_Has_Help_Section()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<NavMenu>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("도움말");
|
||||
cut.Markup.Should().Contain("문서");
|
||||
cut.Markup.Should().Contain("API");
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
@namespace QuantEngine.Web.Client.Components
|
||||
@inject IDialogService DialogService
|
||||
|
||||
@code {
|
||||
public static async Task<bool> Show(IDialogService dialogService, string title, string message, string confirmText = "확인", string cancelText = "취소")
|
||||
{
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseButton = false,
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true,
|
||||
BackdropClick = false
|
||||
};
|
||||
|
||||
var parameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ x => x.Title, title },
|
||||
{ x => x.Message, message },
|
||||
{ x => x.ConfirmText, confirmText },
|
||||
{ x => x.CancelText, cancelText }
|
||||
};
|
||||
|
||||
var dialog = await dialogService.ShowAsync<ConfirmDialog>(title, parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
return !result.Canceled && (bool?)result.Data == true;
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.h6">@Title</MudText>
|
||||
<MudText Typo="Typo.body2">@Message</MudText>
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel" Color="Color.Default">@CancelText</MudButton>
|
||||
<MudButton OnClick="Confirm" Color="Color.Primary" Variant="Variant.Filled">@ConfirmText</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance MudDialog { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Title { get; set; } = "확인";
|
||||
|
||||
[Parameter]
|
||||
public string Message { get; set; } = "";
|
||||
|
||||
[Parameter]
|
||||
public string ConfirmText { get; set; } = "확인";
|
||||
|
||||
[Parameter]
|
||||
public string CancelText { get; set; } = "취소";
|
||||
|
||||
private void Confirm() => MudDialog.Close(DialogResult.Ok(true));
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
@namespace QuantEngine.Web.Client.Components
|
||||
|
||||
<MudStack Spacing="2" Class="form-field">
|
||||
<label class="form-label">
|
||||
@Label
|
||||
@if (Required)
|
||||
{
|
||||
<span class="text-error">*</span>
|
||||
}
|
||||
</label>
|
||||
|
||||
@switch (Type)
|
||||
{
|
||||
case "text":
|
||||
case "email":
|
||||
case "password":
|
||||
case "number":
|
||||
<MudTextField T="string"
|
||||
Value="@Value"
|
||||
ValueChanged="@((string v) => ValueChanged.InvokeAsync(v))"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
Placeholder="@Placeholder"
|
||||
Type="@Type"
|
||||
Required="@Required"
|
||||
ErrorText="@ErrorMessage" />
|
||||
break;
|
||||
|
||||
case "textarea":
|
||||
<MudTextField T="string"
|
||||
Value="@Value"
|
||||
ValueChanged="@((string v) => ValueChanged.InvokeAsync(v))"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
Placeholder="@Placeholder"
|
||||
Lines="5"
|
||||
Required="@Required"
|
||||
ErrorText="@ErrorMessage" />
|
||||
break;
|
||||
|
||||
case "select":
|
||||
<MudSelect T="string"
|
||||
Value="@Value"
|
||||
ValueChanged="@((string v) => ValueChanged.InvokeAsync(v))"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
Required="@Required">
|
||||
@foreach (var option in Options)
|
||||
{
|
||||
<MudSelectItem T="string" Value="@option">@option</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
break;
|
||||
|
||||
case "checkbox":
|
||||
<MudCheckBox T="bool"
|
||||
Checked="@(Value == "true")"
|
||||
CheckedChanged="@((bool v) => ValueChanged.InvokeAsync(v ? "true" : "false"))">
|
||||
@Label
|
||||
</MudCheckBox>
|
||||
break;
|
||||
|
||||
case "date":
|
||||
<MudTextField T="string"
|
||||
Value="@Value"
|
||||
ValueChanged="@((string v) => ValueChanged.InvokeAsync(v))"
|
||||
Variant="Variant.Outlined"
|
||||
FullWidth="true"
|
||||
Type="date"
|
||||
Required="@Required" />
|
||||
break;
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(HelpText))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="text-muted">@HelpText</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string Label { get; set; } = "";
|
||||
|
||||
[Parameter]
|
||||
public string Type { get; set; } = "text";
|
||||
|
||||
[Parameter]
|
||||
public string Value { get; set; } = "";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> ValueChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Placeholder { get; set; } = "";
|
||||
|
||||
[Parameter]
|
||||
public bool Required { get; set; } = false;
|
||||
|
||||
[Parameter]
|
||||
public string ErrorMessage { get; set; } = "";
|
||||
|
||||
[Parameter]
|
||||
public string HelpText { get; set; } = "";
|
||||
|
||||
[Parameter]
|
||||
public List<string> Options { get; set; } = new();
|
||||
}
|
||||
|
||||
<style>
|
||||
.form-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mud-palette-text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-label .text-error {
|
||||
color: var(--mud-palette-error);
|
||||
}
|
||||
</style>
|
||||
-249
@@ -1,249 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.JSInterop;
|
||||
using QuantEngine.Web.Client.Services;
|
||||
|
||||
namespace QuantEngine.Web.Client.Infrastructure
|
||||
{
|
||||
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly LocalStorageService _localStorage;
|
||||
private readonly HttpClient _http;
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private readonly ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
private const string TokenKey = "quant_admin_access_token";
|
||||
private const string UsernameKey = "quant_admin_username";
|
||||
private const string RoleKey = "quant_admin_role";
|
||||
private const string RememberUsernameKey = "quant_admin_remember_username";
|
||||
|
||||
private AuthenticationState? _cachedState;
|
||||
|
||||
public CustomAuthenticationStateProvider(LocalStorageService localStorage, HttpClient http, IJSRuntime jsRuntime)
|
||||
{
|
||||
_localStorage = localStorage;
|
||||
_http = http;
|
||||
_jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
if (_cachedState != null && _cachedState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
Console.WriteLine("[Auth] Returning cached authentication state");
|
||||
return _cachedState;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine("[Auth] GetAuthenticationStateAsync called");
|
||||
|
||||
// Primary: Try to validate via /api/auth/me
|
||||
// This works with both cookies (automatic) and Bearer tokens
|
||||
try
|
||||
{
|
||||
Console.WriteLine("[Auth] Attempting validation via /api/auth/me (cookie or Bearer)...");
|
||||
// BaseAddress is always set to HostEnvironment.BaseAddress by DI.
|
||||
// Never fall back to a hardcoded port — it breaks in production.
|
||||
var meUrl = "api/auth/me";
|
||||
var requestUri = _http.BaseAddress == null ? new Uri($"http://localhost:5265/{meUrl}") : new Uri(_http.BaseAddress, meUrl);
|
||||
Console.WriteLine($"[Auth] /api/auth/me URL: {requestUri}");
|
||||
|
||||
var meResponse = await _http.GetAsync(requestUri);
|
||||
Console.WriteLine($"[Auth] /api/auth/me status: {meResponse.StatusCode}");
|
||||
|
||||
if (meResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await meResponse.Content.ReadAsStringAsync();
|
||||
Console.WriteLine($"[Auth] Response JSON: {json}");
|
||||
|
||||
var meData = System.Text.Json.JsonDocument.Parse(json).RootElement;
|
||||
var authenticated = meData.TryGetProperty("authenticated", out var authProp) && authProp.GetBoolean();
|
||||
var username = meData.TryGetProperty("username", out var userProp) ? userProp.GetString() : null;
|
||||
var role = meData.TryGetProperty("role", out var roleProp) ? roleProp.GetString() : "Admin";
|
||||
|
||||
Console.WriteLine($"[Auth] Parsed: authenticated={authenticated}, username={username}, role={role}");
|
||||
|
||||
if (authenticated && !string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
Console.WriteLine($"[Auth] ✅ SUCCESS: Authenticated as {username}");
|
||||
var identity = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, username),
|
||||
new Claim(ClaimTypes.Role, role ?? "Admin")
|
||||
}, "QuantAdminAuth");
|
||||
|
||||
var state = new AuthenticationState(new ClaimsPrincipal(identity));
|
||||
_cachedState = state;
|
||||
return state;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[Auth] Parsing failed: authenticated={authenticated}, username={username}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[Auth] /api/auth/me returned {meResponse.StatusCode}");
|
||||
|
||||
if (IsLocalhost())
|
||||
{
|
||||
Console.WriteLine("[Auth] Dev SSR fallback: allowing admin authentication on 401");
|
||||
return GetDevAdminState();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception meEx)
|
||||
{
|
||||
Console.WriteLine($"[Auth] /api/auth/me failed: {meEx.Message}");
|
||||
Console.WriteLine($"[Auth] Exception: {meEx}");
|
||||
|
||||
if (IsLocalhost())
|
||||
{
|
||||
Console.WriteLine("[Auth] Dev SSR fallback: allowing admin authentication on exception");
|
||||
return GetDevAdminState();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try to read from localStorage
|
||||
Console.WriteLine("[Auth] Fallback: checking localStorage...");
|
||||
try
|
||||
{
|
||||
string token = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", TokenKey);
|
||||
string username = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", UsernameKey);
|
||||
string role = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", RoleKey);
|
||||
|
||||
Console.WriteLine($"[Auth] localStorage: token={!string.IsNullOrWhiteSpace(token)}, username={username}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
var meUrl = "api/auth/me";
|
||||
var requestUri = _http.BaseAddress == null ? new Uri($"http://localhost:5265/{meUrl}") : new Uri(_http.BaseAddress, meUrl);
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
var response = await _http.SendAsync(request);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"[Auth] ✅ localStorage token validated: {username}");
|
||||
var identity = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, username),
|
||||
new Claim(ClaimTypes.Role, role ?? "Admin")
|
||||
}, "QuantAdminAuth");
|
||||
|
||||
var state = new AuthenticationState(new ClaimsPrincipal(identity));
|
||||
_cachedState = state;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception jsEx)
|
||||
{
|
||||
Console.WriteLine($"[Auth] localStorage fallback failed: {jsEx.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine("[Auth] ❌ Not authenticated");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Auth] Unexpected error: {ex.Message}");
|
||||
}
|
||||
|
||||
_cachedState = new AuthenticationState(_anonymous);
|
||||
return _cachedState;
|
||||
}
|
||||
|
||||
public async Task MarkUserAsAuthenticatedAsync(string username, string accessToken, string role)
|
||||
{
|
||||
await MarkUserAsAuthenticatedAsync(username, accessToken, role, rememberUsername: true);
|
||||
}
|
||||
|
||||
public async Task MarkUserAsAuthenticatedAsync(string username, string accessToken, string role, bool rememberUsername)
|
||||
{
|
||||
await _localStorage.SetAsync(TokenKey, accessToken);
|
||||
if (rememberUsername)
|
||||
{
|
||||
await _localStorage.SetAsync(UsernameKey, username);
|
||||
await _localStorage.SetAsync(RememberUsernameKey, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _localStorage.DeleteAsync(UsernameKey);
|
||||
await _localStorage.SetAsync(RememberUsernameKey, false);
|
||||
}
|
||||
await _localStorage.SetAsync(RoleKey, role);
|
||||
|
||||
var identity = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, username),
|
||||
new Claim(ClaimTypes.Role, role)
|
||||
}, "QuantAdminAuth");
|
||||
|
||||
var user = new ClaimsPrincipal(identity);
|
||||
var state = new AuthenticationState(user);
|
||||
_cachedState = state;
|
||||
NotifyAuthenticationStateChanged(Task.FromResult(state));
|
||||
}
|
||||
|
||||
public async Task MarkUserAsLoggedOutAsync()
|
||||
{
|
||||
await _localStorage.DeleteAsync(TokenKey);
|
||||
await _localStorage.DeleteAsync(RoleKey);
|
||||
var rememberUsername = await _localStorage.GetAsync<bool>(RememberUsernameKey);
|
||||
if (!rememberUsername)
|
||||
{
|
||||
await _localStorage.DeleteAsync(UsernameKey);
|
||||
}
|
||||
_cachedState = new AuthenticationState(_anonymous);
|
||||
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
|
||||
}
|
||||
|
||||
public async Task LogoutFromServerAsync()
|
||||
{
|
||||
var token = await _localStorage.GetAsync<string>(TokenKey);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "api/auth/logout");
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
await _http.SendAsync(request);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort server revocation; always clear local state.
|
||||
}
|
||||
}
|
||||
|
||||
await MarkUserAsLoggedOutAsync();
|
||||
}
|
||||
|
||||
public async Task<string?> GetRememberedUsernameAsync()
|
||||
{
|
||||
var rememberUsername = await _localStorage.GetAsync<bool>(RememberUsernameKey);
|
||||
if (!rememberUsername)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _localStorage.GetAsync<string>(UsernameKey);
|
||||
}
|
||||
|
||||
private bool IsLocalhost()
|
||||
{
|
||||
return _http.BaseAddress == null || _http.BaseAddress.Host == "localhost" || _http.BaseAddress.Host == "127.0.0.1";
|
||||
}
|
||||
|
||||
private AuthenticationState GetDevAdminState()
|
||||
{
|
||||
var identity = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, "admin"),
|
||||
new Claim(ClaimTypes.Role, "Admin")
|
||||
}, "QuantAdminAuth");
|
||||
var state = new AuthenticationState(new ClaimsPrincipal(identity));
|
||||
_cachedState = state;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
@inherits LayoutComponentBase
|
||||
@rendermode InteractiveWebAssembly
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
:global(html, body, #app) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@Body
|
||||
|
||||
@code {
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
/* QuantEngine AuthLayout Styles */
|
||||
|
||||
.auth-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--mud-palette-primary) 0%, var(--mud-palette-primary-dark) 100%);
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
/* Left Panel - Branding */
|
||||
.auth-left-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3rem;
|
||||
color: white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-branding {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
margin-bottom: 2rem;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.auth-logo ::deep svg {
|
||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
|
||||
font-size: 80px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
opacity: 0.9;
|
||||
font-size: 1.1rem;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.auth-features {
|
||||
margin-top: 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.auth-feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.auth-feature ::deep svg {
|
||||
font-size: 24px;
|
||||
color: #4caf50;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-theme-toggle {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
}
|
||||
|
||||
.auth-theme-toggle ::deep button {
|
||||
color: white;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-theme-toggle ::deep button:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Right Panel - Auth Content */
|
||||
.auth-right-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
background: var(--mud-palette-background);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-mobile-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--mud-palette-divider);
|
||||
}
|
||||
|
||||
.auth-mobile-header ::deep .mud-icon {
|
||||
color: var(--mud-palette-primary);
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.auth-content ::deep .mud-card {
|
||||
background: var(--mud-palette-surface);
|
||||
border: 1px solid var(--mud-palette-divider);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.auth-content ::deep .mud-form-control {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-content ::deep .mud-button {
|
||||
text-transform: none;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.auth-content ::deep .mud-button-root {
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.auth-footer {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
padding: 1rem 2rem;
|
||||
border-top: 1px solid var(--mud-palette-divider);
|
||||
}
|
||||
|
||||
.auth-footer-text {
|
||||
display: block;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.auth-footer-links ::deep a {
|
||||
color: var(--mud-palette-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-footer-links ::deep a:hover {
|
||||
color: var(--mud-palette-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 960px) {
|
||||
.auth-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auth-left-panel {
|
||||
padding: 2rem;
|
||||
min-height: 40vh;
|
||||
}
|
||||
|
||||
.auth-right-panel {
|
||||
padding: 3rem 2rem 5rem;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.auth-mobile-header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
bottom: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.auth-right-panel {
|
||||
padding: 2rem 1rem 5rem;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.auth-features {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
position: static;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--mud-palette-divider);
|
||||
margin-top: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode */
|
||||
[data-theme="dark"] .auth-container {
|
||||
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .auth-left-panel {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .auth-right-panel {
|
||||
background: #121212;
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.auth-logo {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.auth-theme-toggle ::deep button {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.auth-footer-links ::deep a {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@Body
|
||||
|
||||
<style>
|
||||
:global(html, body) {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(#app) {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -1,154 +0,0 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using QuantEngine.Web.Client.Theme
|
||||
@inject HttpClient Http
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<!-- ✅ MudBlazor Providers (Required for Interactive WebAssembly) -->
|
||||
<MudThemeProvider Theme="@_theme" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudLayout>
|
||||
<!-- Top Navigation Bar -->
|
||||
<MudAppBar Elevation="1" Dense="false" Color="Color.Surface" Class="mud-appbar-dense">
|
||||
<MudHidden Breakpoint="Breakpoint.SmAndUp" Invert="true">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@(() => navOpen = !navOpen)" />
|
||||
</MudHidden>
|
||||
|
||||
<MudText Typo="Typo.h6" Class="ml-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Dashboard" Class="me-2" />
|
||||
QuantEngine
|
||||
</MudText>
|
||||
|
||||
<MudSpacer />
|
||||
|
||||
<!-- User Menu -->
|
||||
<AuthorizeView Context="authContext">
|
||||
<Authorized>
|
||||
<MudMenu AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopRight" Class="ml-2">
|
||||
<ActivatorContent>
|
||||
<MudAvatar Color="Color.Primary" Image="@GetUserInitials()" Class="cursor-pointer">
|
||||
@GetFirstLetter(authContext.User.Identity?.Name)
|
||||
</MudAvatar>
|
||||
</ActivatorContent>
|
||||
<ChildContent>
|
||||
<MudMenuItem>
|
||||
<MudText Typo="Typo.body2">
|
||||
<strong>@authContext.User.Identity?.Name</strong>
|
||||
</MudText>
|
||||
</MudMenuItem>
|
||||
<MudDivider />
|
||||
<MudMenuItem href="/profile">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Person" Class="mr-2" Size="Size.Small" />
|
||||
프로필
|
||||
</MudMenuItem>
|
||||
<MudMenuItem href="/settings">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Class="mr-2" Size="Size.Small" />
|
||||
설정
|
||||
</MudMenuItem>
|
||||
<MudDivider />
|
||||
<MudMenuItem OnClick="HandleLogoutAsync">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Logout" Class="mr-2" Size="Size.Small" Color="Color.Error" />
|
||||
<MudText Color="Color.Error">로그아웃</MudText>
|
||||
</MudMenuItem>
|
||||
</ChildContent>
|
||||
</MudMenu>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</MudAppBar>
|
||||
|
||||
<!-- Sidebar Navigation -->
|
||||
<MudDrawer Open="@navOpen" Variant="DrawerVariant.Responsive" Elevation="1" FixedOpen="@fixedOpen">
|
||||
<MudDrawerHeader Class="d-flex align-center justify-space-between">
|
||||
<MudText Typo="Typo.h6" Class="px-2">메뉴</MudText>
|
||||
<MudHidden Breakpoint="Breakpoint.Md" Invert="true">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
OnClick="ToggleDrawer"
|
||||
Class="mx-1" />
|
||||
</MudHidden>
|
||||
</MudDrawerHeader>
|
||||
|
||||
<MudNavMenu>
|
||||
<NavMenu />
|
||||
</MudNavMenu>
|
||||
|
||||
<!-- Drawer Footer -->
|
||||
<div class="mud-drawer-footer">
|
||||
<MudDivider />
|
||||
<div style="padding: 16px;">
|
||||
<MudText Typo="Typo.caption">
|
||||
<strong>QuantEngine</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption">
|
||||
v@appVersion
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="mt-2">
|
||||
배포: @buildTime
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</MudDrawer>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<MudMainContent Class="mud-main-content-enhanced">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="pa-6">
|
||||
@Body
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
@code {
|
||||
private MudTheme _theme = AppTheme.LightTheme;
|
||||
private bool navOpen = true;
|
||||
private bool fixedOpen = true;
|
||||
private string appVersion = "Local Debug";
|
||||
private string buildTime = "N/A";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var versionInfo = await Http.GetFromJsonAsync<VersionInfo>("version.json");
|
||||
if (versionInfo != null)
|
||||
{
|
||||
appVersion = versionInfo.Version ?? "Local Debug";
|
||||
buildTime = versionInfo.Built ?? "N/A";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
private void ToggleDrawer()
|
||||
{
|
||||
navOpen = !navOpen;
|
||||
}
|
||||
|
||||
private async Task HandleLogoutAsync()
|
||||
{
|
||||
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
|
||||
await customProvider.LogoutFromServerAsync();
|
||||
NavigationManager.NavigateTo("/Account/Login", forceLoad: true);
|
||||
}
|
||||
|
||||
private string GetFirstLetter(string? name)
|
||||
{
|
||||
return string.IsNullOrEmpty(name) ? "?" : name[0].ToString().ToUpper();
|
||||
}
|
||||
|
||||
private string GetUserInitials()
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private class VersionInfo
|
||||
{
|
||||
public string? Version { get; set; }
|
||||
public string? Built { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/* QuantEngine MainLayout Styles */
|
||||
|
||||
/* AppBar Enhancements */
|
||||
.mud-appbar-dense {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.mud-appbar-dense ::deep .mud-appbar-section-center {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Avatar Styling */
|
||||
::deep .mud-avatar {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
::deep .mud-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Drawer Footer */
|
||||
.mud-drawer-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: var(--mud-palette-surface);
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.mud-main-content-enhanced {
|
||||
min-height: 100vh;
|
||||
background: var(--mud-palette-background);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Navigation Menu Styles */
|
||||
.mud-navmenu {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.mud-navmenu ::deep .mud-nav-item {
|
||||
padding: 0.5rem 0;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.mud-navmenu ::deep .mud-nav-link {
|
||||
border-radius: 0.4rem;
|
||||
margin: 0 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mud-navmenu ::deep .mud-nav-link:hover {
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
|
||||
.mud-navmenu ::deep .mud-nav-link.mud-ripple-nav-link-active {
|
||||
background-color: var(--mud-palette-primary-lighten);
|
||||
color: var(--mud-palette-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive Drawer */
|
||||
@media (max-width: 599px) {
|
||||
.mud-drawer-content {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.mud-drawer-footer {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.mud-drawer-footer {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
/* Error UI */
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Dark Mode Transitions */
|
||||
* {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<MudNavMenu>
|
||||
<!-- Main Navigation -->
|
||||
<MudNavLink Href="/dashboard" Icon="@Icons.Material.Filled.Dashboard" Match="NavLinkMatch.All">
|
||||
대시보드
|
||||
</MudNavLink>
|
||||
|
||||
<!-- Admin Section -->
|
||||
<MudNavGroup Title="관리" Icon="@Icons.Material.Filled.AdminPanelSettings" Expanded="true">
|
||||
<MudNavLink Href="/users" Icon="@Icons.Material.Filled.People">사용자 관리</MudNavLink>
|
||||
<MudNavLink Href="/collection" Icon="@Icons.Material.Filled.CloudDownload">데이터 수집</MudNavLink>
|
||||
<MudNavLink Href="/monitoring" Icon="@Icons.Material.Filled.Timeline">수집 모니터링</MudNavLink>
|
||||
</MudNavGroup>
|
||||
|
||||
<!-- Operations -->
|
||||
<MudNavLink Href="/operations" Icon="@Icons.Material.Filled.PlaylistPlay" Match="NavLinkMatch.Prefix">
|
||||
운영 리포트
|
||||
</MudNavLink>
|
||||
</MudNavMenu>
|
||||
@@ -1,105 +0,0 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
@page "/collection"
|
||||
@attribute [Authorize]
|
||||
@using QuantEngine.Web.Client.Services
|
||||
@inject ApiClient ApiClient
|
||||
@inject ILogger<Collection> Logger
|
||||
|
||||
<PageTitle>QuantEngine - Collection</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-2">Data Collection</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-4">KIS API data collection dashboard. API-first로만 동작합니다.</MudText>
|
||||
|
||||
<MudStack Row="true" Spacing="2" Class="mb-4">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@StartCollectionAsync" Disabled="@IsProcessing">
|
||||
@(IsProcessing ? "Running..." : "Start Collection")
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@RefreshAsync" Disabled="@IsProcessing">Refresh</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-4" />
|
||||
}
|
||||
else if (DashboardState != null)
|
||||
{
|
||||
<MudGrid Spacing="2" Class="mb-4">
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.caption">Last Run</MudText>
|
||||
<MudText Typo="Typo.h6">@(DashboardState.LastRunStatus ?? "N/A")</MudText>
|
||||
<MudText Typo="Typo.body2">@(DashboardState.LastFinishedAt ?? "Not finished")</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.caption">Total Snapshots</MudText>
|
||||
<MudText Typo="Typo.h6">@DashboardState.TotalSnapshots</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.caption">Total Errors</MudText>
|
||||
<MudText Typo="Typo.h6">@DashboardState.TotalErrors</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@if (DashboardState.RecentErrors.Count > 0)
|
||||
{
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-3">Recent Errors</MudText>
|
||||
<MudTable Items="@DashboardState.RecentErrors" Dense="true" Hover="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Source</MudTh>
|
||||
<MudTh>Kind</MudTh>
|
||||
<MudTh>Ticker</MudTh>
|
||||
<MudTh>Message</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Source">@context.SourceName</MudTd>
|
||||
<MudTd DataLabel="Kind">@context.ErrorKind</MudTd>
|
||||
<MudTd DataLabel="Ticker">@context.Ticker</MudTd>
|
||||
<MudTd DataLabel="Message">@context.ErrorMessage</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@if (RecentRuns != null && RecentRuns.Count > 0)
|
||||
{
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-3">Recent Runs</MudText>
|
||||
<MudTable Items="@RecentRuns" Dense="true" Hover="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Run ID</MudTh>
|
||||
<MudTh>Status</MudTh>
|
||||
<MudTh>Started</MudTh>
|
||||
<MudTh>Finished</MudTh>
|
||||
<MudTh>Snapshots</MudTh>
|
||||
<MudTh>Errors</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Run ID" Style="font-family: monospace; font-size: 12px;">@context.RunId</MudTd>
|
||||
<MudTd DataLabel="Status">@context.Status</MudTd>
|
||||
<MudTd DataLabel="Started">@context.StartedAt</MudTd>
|
||||
<MudTd DataLabel="Finished">@context.FinishedAt</MudTd>
|
||||
<MudTd DataLabel="Snapshots">@context.TotalSnapshots</MudTd>
|
||||
<MudTd DataLabel="Errors">@context.TotalErrors</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudPaper>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private CollectionDashboardStateDto? DashboardState;
|
||||
private List<CollectionRunDto>? RecentRuns;
|
||||
private bool IsLoading = true;
|
||||
private bool IsProcessing = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDashboardStateAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDashboardStateAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
try
|
||||
{
|
||||
// Parallelize API calls to avoid sequential RTT bottlenecks
|
||||
var stateTask = ApiClient.GetCollectionStateAsync();
|
||||
var runsTask = ApiClient.GetCollectionRunsAsync(10);
|
||||
|
||||
await Task.WhenAll(stateTask, runsTask);
|
||||
|
||||
DashboardState = await stateTask;
|
||||
var runsResponse = await runsTask;
|
||||
RecentRuns = runsResponse?.Runs ?? new();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error loading dashboard");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartCollectionAsync()
|
||||
{
|
||||
IsProcessing = true;
|
||||
try
|
||||
{
|
||||
var result = await ApiClient.StartCollectionRunAsync();
|
||||
if (result != null)
|
||||
{
|
||||
await LoadDashboardStateAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error starting collection");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
await LoadDashboardStateAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
@page "/dashboard"
|
||||
@rendermode InteractiveWebAssembly
|
||||
|
||||
@using QuantEngine.Core.Infrastructure
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@inject HttpClient Http
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavManager
|
||||
|
||||
<PageTitle>QuantEngine - Admin Dashboard</PageTitle>
|
||||
|
||||
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6">
|
||||
<MudText Typo="Typo.h4" Class="mb-2">관리자 대시보드</MudText>
|
||||
<MudText Typo="Typo.body1" Class="text-muted">시스템 현황 및 데이터 수집 모니터링</MudText>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<MudGrid Spacing="3" Class="mb-6">
|
||||
<!-- Total Runs -->
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4 mud-card-kpi" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">총 수집 실행</MudText>
|
||||
<MudText Typo="Typo.h5" Class="text-primary">@TotalRuns</MudText>
|
||||
<MudText Typo="Typo.body2" Class="text-muted mt-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.TrendingUp" Size="Size.Small" Style="color: #4caf50;" />
|
||||
이번 주 +@WeeklyRuns
|
||||
</MudText>
|
||||
</div>
|
||||
<MudIcon Icon="@Icons.Material.Filled.PlayCircleOutline" Size="Size.Large" Class="text-primary" Style="opacity: 0.3;" />
|
||||
</div>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- Success Rate -->
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4 mud-card-kpi" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">성공률</MudText>
|
||||
<MudText Typo="Typo.h5" Class="text-success">@SuccessRate%</MudText>
|
||||
<MudText Typo="Typo.body2" Class="text-muted mt-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Size="Size.Small" Style="color: #4caf50;" />
|
||||
최근 30일
|
||||
</MudText>
|
||||
</div>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Assessment" Size="Size.Large" Class="text-success" Style="opacity: 0.3;" />
|
||||
</div>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- Recent Errors -->
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4 mud-card-kpi" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">최근 에러</MudText>
|
||||
<MudText Typo="Typo.h5" Class="text-error">@RecentErrors</MudText>
|
||||
<MudText Typo="Typo.body2" Class="text-muted mt-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ErrorOutline" Size="Size.Small" Style="color: #f44336;" />
|
||||
지난 7일
|
||||
</MudText>
|
||||
</div>
|
||||
<MudIcon Icon="@Icons.Material.Filled.WarningAmber" Size="Size.Large" Class="text-error" Style="opacity: 0.3;" />
|
||||
</div>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- Last Sync -->
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4 mud-card-kpi" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">마지막 동기화</MudText>
|
||||
<MudText Typo="Typo.h5">@LastSyncTime</MudText>
|
||||
<MudText Typo="Typo.body2" Class="text-muted mt-2">
|
||||
<MudChip T="string" Label="true" Size="Size.Small"
|
||||
Color="@(IsLastSyncSuccess ? Color.Success : Color.Warning)"
|
||||
Variant="Variant.Filled">
|
||||
@(IsLastSyncSuccess ? "성공" : "경고")
|
||||
</MudChip>
|
||||
</MudText>
|
||||
</div>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Large" Class="text-secondary" Style="opacity: 0.3;" />
|
||||
</div>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<MudGrid Spacing="3" Class="mb-6">
|
||||
<!-- Recent Activity Feed -->
|
||||
<MudItem xs="12" md="8">
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="mb-4">최근 활동</MudText>
|
||||
|
||||
@if (RecentActivities.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">활동 기록이 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="2">
|
||||
@foreach (var activity in RecentActivities)
|
||||
{
|
||||
<div class="d-flex gap-3 pa-2" style="border-left: 3px solid @GetActivityColor(activity.Type); padding-left: 12px;">
|
||||
<MudIcon Icon="@GetActivityIcon(activity.Type)" Size="Size.Medium" Color="@GetActivityColorEnum(activity.Type)" />
|
||||
<div style="flex: 1;">
|
||||
<MudText Typo="Typo.body2" Class="font-weight-500">@activity.Title</MudText>
|
||||
<MudText Typo="Typo.caption" Class="text-muted">@activity.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mt-1">@activity.Description</MudText>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- System Status -->
|
||||
<MudItem xs="12" md="4">
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="mb-4">시스템 상태</MudText>
|
||||
|
||||
<MudStack Spacing="2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<MudText Typo="Typo.body2">API 서버</MudText>
|
||||
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">온라인</MudChip>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<MudText Typo="Typo.body2">데이터베이스</MudText>
|
||||
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">연결됨</MudChip>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<MudText Typo="Typo.body2">KIS API</MudText>
|
||||
<MudChip T="string" Label="true" Size="Size.Small" Color="@(KisApiStatus ? Color.Success : Color.Warning)" Variant="Variant.Filled">
|
||||
@(KisApiStatus ? "활성" : "비활성")
|
||||
</MudChip>
|
||||
</div>
|
||||
<MudDivider Class="my-2" />
|
||||
<MudText Typo="Typo.caption" Class="text-muted">마지막 점검: @SystemCheckTime</MudText>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<!-- Collections Table -->
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<MudText Typo="Typo.h6">최근 데이터 수집 실행</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small" OnClick="RefreshData">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Refresh" Size="Size.Small" Class="mr-2" />
|
||||
새로고침
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (Sections.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">데이터 수집 기록이 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="@Sections" Dense="true" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>이름</MudTh>
|
||||
<MudTh>상태</MudTh>
|
||||
<MudTh>시작 시간</MudTh>
|
||||
<MudTh>작업</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">
|
||||
<MudText Typo="Typo.body2">@context.Name</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Status">
|
||||
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">
|
||||
@context.Title
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Timestamp">
|
||||
<MudText Typo="Typo.body2">@context.Preview</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary">상세</MudButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<style>
|
||||
.mud-card-kpi {
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mud-card-kpi:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--mud-palette-primary) !important;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--mud-palette-success) !important;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--mud-palette-error) !important;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--mud-palette-text-secondary) !important;
|
||||
}
|
||||
|
||||
.font-weight-500 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gap-3 {
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private readonly List<OperationalReportSection> Sections = new();
|
||||
private readonly List<ActivityLog> RecentActivities = new();
|
||||
|
||||
// KPI values
|
||||
private int TotalRuns = 47;
|
||||
private int WeeklyRuns = 12;
|
||||
private int SuccessRate = 94;
|
||||
private int RecentErrors = 3;
|
||||
private string LastSyncTime = "2분 전";
|
||||
private bool IsLastSyncSuccess = true;
|
||||
private bool KisApiStatus = true;
|
||||
private string SystemCheckTime = DateTime.Now.ToString("HH:mm:ss");
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
if (!(authState.User.Identity?.IsAuthenticated ?? false))
|
||||
{
|
||||
NavManager.NavigateTo("/Account/Login", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var report = await Http.GetFromJsonAsync<OperationalReportData>("api/operational-report");
|
||||
if (report != null)
|
||||
{
|
||||
Sections.Clear();
|
||||
Sections.AddRange(report.Sections);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Handle error silently
|
||||
}
|
||||
|
||||
LoadRecentActivities();
|
||||
}
|
||||
|
||||
private void LoadRecentActivities()
|
||||
{
|
||||
RecentActivities.Clear();
|
||||
RecentActivities.AddRange(new[]
|
||||
{
|
||||
new ActivityLog
|
||||
{
|
||||
Type = "success",
|
||||
Title = "데이터 수집 완료",
|
||||
Description = "삼성전자(005930) 주가 데이터 수집 성공",
|
||||
Timestamp = DateTime.Now.AddMinutes(-5)
|
||||
},
|
||||
new ActivityLog
|
||||
{
|
||||
Type = "warning",
|
||||
Title = "API 레이트 제한",
|
||||
Description = "KIS API 레이트 제한에 도달했으나 재시도 예정",
|
||||
Timestamp = DateTime.Now.AddMinutes(-12)
|
||||
},
|
||||
new ActivityLog
|
||||
{
|
||||
Type = "success",
|
||||
Title = "대시보드 업데이트",
|
||||
Description = "포트폴리오 구성 분석 완료",
|
||||
Timestamp = DateTime.Now.AddMinutes(-35)
|
||||
},
|
||||
new ActivityLog
|
||||
{
|
||||
Type = "info",
|
||||
Title = "스케줄 실행",
|
||||
Description = "일일 정기 수집 작업 시작",
|
||||
Timestamp = DateTime.Now.AddHours(-1)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
await OnInitializedAsync();
|
||||
}
|
||||
|
||||
private string GetActivityIcon(string type) => type switch
|
||||
{
|
||||
"success" => Icons.Material.Filled.CheckCircle,
|
||||
"warning" => Icons.Material.Filled.WarningAmber,
|
||||
"error" => Icons.Material.Filled.Error,
|
||||
_ => Icons.Material.Filled.Info
|
||||
};
|
||||
|
||||
private string GetActivityColor(string type) => type switch
|
||||
{
|
||||
"success" => "#4caf50",
|
||||
"warning" => "#ff9800",
|
||||
"error" => "#f44336",
|
||||
_ => "#2196f3"
|
||||
};
|
||||
|
||||
private Color GetActivityColorEnum(string type) => type switch
|
||||
{
|
||||
"success" => Color.Success,
|
||||
"warning" => Color.Warning,
|
||||
"error" => Color.Error,
|
||||
_ => Color.Info
|
||||
};
|
||||
|
||||
private class ActivityLog
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Description { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
@page "/monitoring"
|
||||
@attribute [Authorize]
|
||||
@inject HttpClient Http
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>QuantEngine - 데이터 수집 모니터링</PageTitle>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<MudText Typo="Typo.h4" Class="mb-2">데이터 수집 모니터링</MudText>
|
||||
<MudText Typo="Typo.body1" Class="text-muted">실시간 수집 작업 상태 및 에러 추적</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small"
|
||||
OnClick="RefreshAsync" Disabled="_loading">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Refresh" Size="Size.Small" Class="mr-2" />
|
||||
새로고침
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-4" />
|
||||
}
|
||||
|
||||
<!-- Collection Status Cards -->
|
||||
<MudGrid Spacing="3" Class="mb-6">
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
||||
<MudText Typo="Typo.caption" Class="text-muted mb-2">진행 중인 작업</MudText>
|
||||
<MudText Typo="Typo.h5">@_runningCount</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
||||
<MudText Typo="Typo.caption" Class="text-muted mb-2">완료</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-success);">@_completedCount</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
||||
<MudText Typo="Typo.caption" Class="text-muted mb-2">실패</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-error);">@_failedCount</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
||||
<MudText Typo="Typo.caption" Class="text-muted mb-2">총 스냅샷</MudText>
|
||||
<MudText Typo="Typo.h5">@_totalSnapshots</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<!-- Tabs -->
|
||||
<MudTabs Outlined="true" Class="mb-6">
|
||||
<!-- Recent Runs -->
|
||||
<MudTabPanel Text="최근 실행">
|
||||
<div class="py-4">
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
@if (_recentRuns.Count == 0 && !_loading)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">최근 실행 기록이 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="@_recentRuns" Dense="true" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>실행 ID</MudTh>
|
||||
<MudTh>시작 시간</MudTh>
|
||||
<MudTh>종료 시간</MudTh>
|
||||
<MudTh>상태</MudTh>
|
||||
<MudTh>스냅샷</MudTh>
|
||||
<MudTh>에러</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Run ID">
|
||||
<MudText Typo="Typo.body2" Class="font-monospace">@context.RunId</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Start">
|
||||
<MudText Typo="Typo.body2">@FormatTime(context.StartedAt)</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="End">
|
||||
<MudText Typo="Typo.body2">@(string.IsNullOrEmpty(context.FinishedAt) ? "-" : FormatTime(context.FinishedAt))</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Status">
|
||||
<MudChip T="string" Label="true" Size="Size.Small"
|
||||
Color="@GetStatusColor(context.Status)"
|
||||
Variant="Variant.Filled">
|
||||
@context.Status
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Snapshots">
|
||||
<MudText Typo="Typo.body2">@(context.TotalSnapshots?.ToString() ?? "-")</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Errors">
|
||||
@if (context.TotalErrors > 0)
|
||||
{
|
||||
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Error" Variant="Variant.Outlined">
|
||||
@context.TotalErrors
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2">-</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudPaper>
|
||||
</div>
|
||||
</MudTabPanel>
|
||||
|
||||
<!-- Error Logs -->
|
||||
<MudTabPanel Text="에러 로그">
|
||||
<div class="py-4">
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
@if (_errors.Count == 0 && !_loading)
|
||||
{
|
||||
<MudAlert Severity="Severity.Success">에러가 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="2">
|
||||
@foreach (var error in _errors)
|
||||
{
|
||||
<div class="pa-3" style="border-left: 3px solid #f44336; background-color: var(--mud-palette-surface);">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<MudText Typo="Typo.body2" Class="font-weight-500">[@error.ErrorKind] @error.ErrorMessage</MudText>
|
||||
<MudText Typo="Typo.caption" Class="text-muted">@FormatTime(error.CreatedAt)</MudText>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Class="text-muted">Run: @error.RunId</MudText>
|
||||
@if (!string.IsNullOrEmpty(error.Ticker))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="text-muted ml-3">Ticker: @error.Ticker</MudText>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudPaper>
|
||||
</div>
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
|
||||
@code {
|
||||
private bool _loading = false;
|
||||
private int _runningCount;
|
||||
private int _completedCount;
|
||||
private int _failedCount;
|
||||
private int _totalSnapshots;
|
||||
|
||||
private List<CollectionRunDto> _recentRuns = new();
|
||||
private List<CollectionErrorDto> _errors = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
_loading = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// 최근 실행 목록 로드
|
||||
var runsResponse = await Http.GetFromJsonAsync<CollectionRunsResponse>("api/collection/runs?limit=20");
|
||||
if (runsResponse?.Runs is not null)
|
||||
{
|
||||
_recentRuns = runsResponse.Runs;
|
||||
_runningCount = _recentRuns.Count(r => string.Equals(r.Status, "running", StringComparison.OrdinalIgnoreCase));
|
||||
_completedCount = _recentRuns.Count(r => string.Equals(r.Status, "completed", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(r.Status, "PASS", StringComparison.OrdinalIgnoreCase));
|
||||
_failedCount = _recentRuns.Count(r => string.Equals(r.Status, "failed", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(r.Status, "error", StringComparison.OrdinalIgnoreCase));
|
||||
_totalSnapshots = _recentRuns.Sum(r => r.TotalSnapshots ?? 0);
|
||||
}
|
||||
|
||||
// 대시보드 상태 로드 (전체 오류 목록)
|
||||
var state = await Http.GetFromJsonAsync<CollectionDashboardStateDto>("api/collection/state");
|
||||
if (state?.RecentErrors is not null)
|
||||
{
|
||||
_errors = state.RecentErrors;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetStatusColor(string status) => status?.ToLowerInvariant() switch
|
||||
{
|
||||
"running" => Color.Info,
|
||||
"completed" => Color.Success,
|
||||
"pass" => Color.Success,
|
||||
"failed" => Color.Error,
|
||||
"error" => Color.Error,
|
||||
_ => Color.Warning
|
||||
};
|
||||
|
||||
private string FormatTime(string? isoTime)
|
||||
{
|
||||
if (string.IsNullOrEmpty(isoTime)) return "-";
|
||||
return DateTimeOffset.TryParse(isoTime, out var dt)
|
||||
? dt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
: isoTime;
|
||||
}
|
||||
|
||||
// DTOs (shared with ApiClient)
|
||||
private record CollectionRunsResponse(List<CollectionRunDto> Runs, int Count);
|
||||
private record CollectionRunDto(
|
||||
string RunId, string Status, string StartedAt,
|
||||
string? FinishedAt, int? TotalSnapshots, int? TotalErrors);
|
||||
private record CollectionDashboardStateDto(
|
||||
string? LastRunId, string? LastRunStatus, string? LastFinishedAt,
|
||||
int TotalSnapshots, int TotalErrors, List<CollectionErrorDto> RecentErrors);
|
||||
private record CollectionErrorDto(
|
||||
string RunId, string SourceName, string ErrorKind,
|
||||
string ErrorMessage, string? Ticker, string CreatedAt);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<!-- 🎯 DEBUG MARKER: NOTFOUND_RENDERING -->
|
||||
<div id="notfound-debug-marker" style="display:none;">NOTFOUND_RENDERING_ACTIVE</div>
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
@@ -1,121 +0,0 @@
|
||||
@page "/operations"
|
||||
@attribute [Authorize]
|
||||
@using QuantEngine.Core.Infrastructure
|
||||
@inject HttpClient Http
|
||||
|
||||
<PageTitle>QuantEngine - Operations</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-2">Operational Report</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-4">Temp/operational_report.json만 읽는 운영 고정 화면입니다.</MudText>
|
||||
|
||||
<MudGrid Spacing="2" Class="mb-4">
|
||||
<MudItem xs="12" sm="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.caption">Schema</MudText>
|
||||
<MudText Typo="Typo.h6">@SchemaVersion</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.caption">Sections</MudText>
|
||||
<MudText Typo="Typo.h6">@SectionCountLabel</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.caption">Source</MudText>
|
||||
<MudText Typo="Typo.h6">@SourceJson</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.caption">Generated</MudText>
|
||||
<MudText Typo="Typo.h6">@GeneratedAt</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudGrid Spacing="2" Class="mb-4">
|
||||
@foreach (var section in HighlightSections)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="3" @key="section.Name">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.caption">@(section.Name)</MudText>
|
||||
<MudText Typo="Typo.h6">@(section.Title)</MudText>
|
||||
<MudText Typo="Typo.body2">@(section.Preview)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-3">Report Health</MudText>
|
||||
<MudStack Spacing="1">
|
||||
<MudText Typo="Typo.body2">Status: <MudChip T="string" Color="@(HealthLabel == "PASS" ? Color.Success : Color.Warning)" Variant="Variant.Filled">@HealthLabel</MudChip></MudText>
|
||||
<MudText Typo="Typo.body2">Path: @ReportPath</MudText>
|
||||
<MudText Typo="Typo.body2">Sections rendered: @RenderedSectionCountLabel</MudText>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-3">Sections</MudText>
|
||||
@if (Sections.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning">DATA_MISSING: operational_report.json에 표시할 섹션이 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="@Sections" Dense="true" Hover="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Title</MudTh>
|
||||
<MudTh>Preview</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Title">@context.Title</MudTd>
|
||||
<MudTd DataLabel="Preview">@context.Preview</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private readonly List<OperationalReportSection> Sections = new();
|
||||
private readonly List<OperationalReportSection> HighlightSections = new();
|
||||
private string SchemaVersion = "n/a";
|
||||
private string SourceJson = "n/a";
|
||||
private string GeneratedAt = "n/a";
|
||||
private string SectionCountLabel = "0";
|
||||
private string RenderedSectionCountLabel = "0";
|
||||
private string HealthLabel = "DATA_MISSING";
|
||||
private string ReportPath = "n/a";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var report = await Http.GetFromJsonAsync<OperationalReportData>("api/operational-report");
|
||||
if (report != null)
|
||||
{
|
||||
SchemaVersion = report.SchemaVersion;
|
||||
SourceJson = report.SourceJson;
|
||||
GeneratedAt = report.GeneratedAt;
|
||||
|
||||
Sections.Clear();
|
||||
Sections.AddRange(report.Sections);
|
||||
|
||||
HighlightSections.Clear();
|
||||
HighlightSections.AddRange(Sections.Take(4));
|
||||
|
||||
SectionCountLabel = report.SectionCount.ToString();
|
||||
RenderedSectionCountLabel = Sections.Count.ToString();
|
||||
HealthLabel = Sections.Count > 0 ? "PASS" : "DATA_MISSING";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
HealthLabel = "DATA_MISSING";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
@page "/portfolio"
|
||||
@attribute [Authorize]
|
||||
@inject HttpClient Http
|
||||
|
||||
<PageTitle>QuantEngine - 포트폴리오</PageTitle>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6">
|
||||
<MudText Typo="Typo.h4" Class="mb-2">포트폴리오</MudText>
|
||||
<MudText Typo="Typo.body1" Class="text-muted">자산 구성 및 성과 분석</MudText>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<MudGrid Spacing="3" Class="mb-6">
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">총 평가액</MudText>
|
||||
<MudText Typo="Typo.h5" Class="text-primary">₩125.5M</MudText>
|
||||
<MudText Typo="Typo.body2" Class="text-success mt-1">+3.2% (이번 달)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">보유 종목</MudText>
|
||||
<MudText Typo="Typo.h5">12개</MudText>
|
||||
<MudText Typo="Typo.body2" Class="text-muted mt-1">주식 및 펀드</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">수익률</MudText>
|
||||
<MudText Typo="Typo.h5" Class="text-success">+8.5%</MudText>
|
||||
<MudText Typo="Typo.body2" Class="text-muted mt-1">연간 기준</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="0" Style="border: 1px solid var(--mud-palette-divider);">
|
||||
<MudText Typo="Typo.caption" Class="text-muted mb-1">위험도</MudText>
|
||||
<MudText Typo="Typo.h5">중간</MudText>
|
||||
<MudChip T="string" Label="true" Size="Size.Small" Color="Color.Warning" Variant="Variant.Filled" Class="mt-1">
|
||||
Moderate
|
||||
</MudChip>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<!-- Asset Breakdown -->
|
||||
<MudGrid Spacing="3" Class="mb-6">
|
||||
<MudItem xs="12" md="8">
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="mb-4">자산 구성</MudText>
|
||||
|
||||
<MudTable Items="@_assets" Dense="true" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>종목/펀드명</MudTh>
|
||||
<MudTh>수량</MudTh>
|
||||
<MudTh>현재가</MudTh>
|
||||
<MudTh>평가액</MudTh>
|
||||
<MudTh>수익률</MudTh>
|
||||
<MudTh>비율</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<MudAvatar Size="Size.Small" Color="Color.Primary">@context.Name[0]</MudAvatar>
|
||||
<div>
|
||||
<MudText Typo="Typo.body2" Class="font-weight-500">@context.Name</MudText>
|
||||
<MudText Typo="Typo.caption" Class="text-muted">@context.Ticker</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Quantity">
|
||||
<MudText Typo="Typo.body2">@context.Quantity.ToString("N0")</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Price">
|
||||
<MudText Typo="Typo.body2">₩@context.CurrentPrice.ToString("N0")</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value">
|
||||
<MudText Typo="Typo.body2" Class="font-weight-500">₩@context.Value.ToString("N0")</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Return">
|
||||
<MudChip T="string" Label="true" Size="Size.Small"
|
||||
Color="@(context.ReturnRate >= 0 ? Color.Success : Color.Error)"
|
||||
Variant="Variant.Filled">
|
||||
@(context.ReturnRate >= 0 ? "+" : "")@context.ReturnRate.ToString("F1")%
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Ratio">
|
||||
<MudText Typo="Typo.body2">@context.Ratio.ToString("F1")%</MudText>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="4">
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="mb-4">자산 분류</MudText>
|
||||
|
||||
<MudStack Spacing="2">
|
||||
@foreach (var category in AssetCategories)
|
||||
{
|
||||
<div>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<MudText Typo="Typo.body2">@category.Name</MudText>
|
||||
<MudText Typo="Typo.body2" Class="font-weight-500">@category.Percentage%</MudText>
|
||||
</div>
|
||||
<MudProgressLinear Value="@category.Percentage" Color="@category.Color" />
|
||||
</div>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<!-- Trading History -->
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="mb-4">거래 이력</MudText>
|
||||
|
||||
@if (TradingHistory.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">거래 이력이 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="@TradingHistory" Dense="true" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>일자</MudTh>
|
||||
<MudTh>종목</MudTh>
|
||||
<MudTh>구분</MudTh>
|
||||
<MudTh>수량</MudTh>
|
||||
<MudTh>단가</MudTh>
|
||||
<MudTh>금액</MudTh>
|
||||
<MudTh>수수료</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Date">
|
||||
<MudText Typo="Typo.body2">@context.Date.ToString("yyyy-MM-dd")</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Ticker">
|
||||
<MudText Typo="Typo.body2">@context.Ticker</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Type">
|
||||
<MudChip T="string" Label="true" Size="Size.Small"
|
||||
Color="@(context.Type == "매수" ? Color.Success : Color.Error)"
|
||||
Variant="Variant.Filled">
|
||||
@context.Type
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Quantity">
|
||||
<MudText Typo="Typo.body2">@context.Quantity</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Price">
|
||||
<MudText Typo="Typo.body2">₩@context.Price.ToString("N0")</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Amount">
|
||||
<MudText Typo="Typo.body2">₩@context.Amount.ToString("N0")</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Fee">
|
||||
<MudText Typo="Typo.body2" Class="text-muted">₩@context.Fee.ToString("N0")</MudText>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private List<AssetModel> _assets = new();
|
||||
private List<CategoryModel> AssetCategories = new();
|
||||
private List<TradeModel> TradingHistory = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAssets();
|
||||
}
|
||||
|
||||
private async Task LoadAssets()
|
||||
{
|
||||
_assets = new List<AssetModel>
|
||||
{
|
||||
new AssetModel { Name = "삼성전자", Ticker = "005930", Quantity = 50, CurrentPrice = 70000, Value = 3500000, ReturnRate = 5.2M, Ratio = 28.0M },
|
||||
new AssetModel { Name = "LG화학", Ticker = "051910", Quantity = 30, CurrentPrice = 820000, Value = 24600000, ReturnRate = -2.1M, Ratio = 19.6M },
|
||||
new AssetModel { Name = "현대차", Ticker = "005380", Quantity = 40, CurrentPrice = 245000, Value = 9800000, ReturnRate = 8.5M, Ratio = 7.8M },
|
||||
new AssetModel { Name = "SK하이닉스", Ticker = "000660", Quantity = 25, CurrentPrice = 105000, Value = 2625000, ReturnRate = 12.3M, Ratio = 2.1M },
|
||||
new AssetModel { Name = "삼성중공업", Ticker = "010140", Quantity = 60, CurrentPrice = 85000, Value = 5100000, ReturnRate = 3.7M, Ratio = 4.1M },
|
||||
new AssetModel { Name = "포스코", Ticker = "005490", Quantity = 20, CurrentPrice = 75000, Value = 1500000, ReturnRate = -5.2M, Ratio = 1.2M },
|
||||
};
|
||||
|
||||
AssetCategories = new List<CategoryModel>
|
||||
{
|
||||
new CategoryModel { Name = "대형주", Percentage = 45, Color = Color.Primary },
|
||||
new CategoryModel { Name = "중형주", Percentage = 30, Color = Color.Secondary },
|
||||
new CategoryModel { Name = "소형주", Percentage = 15, Color = Color.Info },
|
||||
new CategoryModel { Name = "채권/현금", Percentage = 10, Color = Color.Success }
|
||||
};
|
||||
|
||||
TradingHistory = new List<TradeModel>
|
||||
{
|
||||
new TradeModel { Date = DateTime.Now.AddDays(-5), Ticker = "005930", Type = "매수", Quantity = 10, Price = 68000, Amount = 680000, Fee = 1360 },
|
||||
new TradeModel { Date = DateTime.Now.AddDays(-10), Ticker = "051910", Type = "매도", Quantity = 5, Price = 850000, Amount = 4250000, Fee = 8500 },
|
||||
new TradeModel { Date = DateTime.Now.AddDays(-15), Ticker = "005380", Type = "매수", Quantity = 20, Price = 240000, Amount = 4800000, Fee = 9600 },
|
||||
};
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private class AssetModel
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Ticker { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public decimal CurrentPrice { get; set; }
|
||||
public decimal Value { get; set; }
|
||||
public decimal ReturnRate { get; set; }
|
||||
public decimal Ratio { get; set; }
|
||||
}
|
||||
|
||||
private class CategoryModel
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Percentage { get; set; }
|
||||
public Color Color { get; set; }
|
||||
}
|
||||
|
||||
private class TradeModel
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public string Ticker { get; set; }
|
||||
public string Type { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public decimal Fee { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
@page "/users"
|
||||
@attribute [Authorize]
|
||||
@using MudBlazor
|
||||
@inject HttpClient Http
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageTitle>QuantEngine - 사용자 관리</PageTitle>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6">
|
||||
<MudText Typo="Typo.h4" Class="mb-2">사용자 관리</MudText>
|
||||
<MudText Typo="Typo.body1" Class="text-muted">시스템 사용자 및 권한 관리</MudText>
|
||||
</div>
|
||||
|
||||
<!-- Action Bar -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<MudTextField @bind-Value="SearchQuery" Placeholder="사용자 검색..."
|
||||
StartAdornment="@Icons.Material.Filled.Search"
|
||||
Style="width: 300px;" />
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenAddUserDialog">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-2" />
|
||||
새 사용자 추가
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
@if (_users.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">사용자가 없습니다.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="@FilteredUsers" Dense="true" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>이름</MudTh>
|
||||
<MudTh>역할</MudTh>
|
||||
<MudTh>상태</MudTh>
|
||||
<MudTh>생성일</MudTh>
|
||||
<MudTh>수정일</MudTh>
|
||||
<MudTh>작업</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<MudAvatar Size="Size.Small" Color="Color.Primary">@context.Username[0].ToString().ToUpper()</MudAvatar>
|
||||
<MudText Typo="Typo.body2">@context.Username</MudText>
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Role">
|
||||
<MudChip T="string" Label="true" Size="Size.Small"
|
||||
Color="@(context.Role == "Admin" ? Color.Primary : (context.Role == "Operator" ? Color.Secondary : Color.Default))"
|
||||
Variant="Variant.Filled">
|
||||
@context.Role
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Status">
|
||||
<MudChip T="string" Label="true" Size="Size.Small"
|
||||
Color="@(context.IsActive ? Color.Success : Color.Warning)"
|
||||
Variant="Variant.Filled">
|
||||
@(context.IsActive ? "활성" : "비활성")
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Joined">
|
||||
<MudText Typo="Typo.body2">@FormatDate(context.CreatedAt)</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Updated">
|
||||
<MudText Typo="Typo.body2">@FormatDate(context.UpdatedAt)</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" OnClick="@(() => EditUser(context))">편집</MudButton>
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteUser(context))">삭제</MudButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<!-- Add/Edit Dialog -->
|
||||
<MudDialog @bind-Visible="_dialogVisible" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">
|
||||
<MudIcon Icon="@(_isEditMode ? Icons.Material.Filled.Edit : Icons.Material.Filled.Add)" Class="mr-3" />
|
||||
@(_isEditMode ? "사용자 편집" : "새 사용자 추가")
|
||||
</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudForm Model="@_formModel" @ref="_form">
|
||||
<MudTextField T="string" @bind-Value="_formModel.Username" Label="사용자 ID" Required="true" Disabled="@_isEditMode"
|
||||
RequiredError="사용자 ID를 입력해 주세요." Class="mb-3" />
|
||||
|
||||
<MudTextField T="string" @bind-Value="_formModel.Password" Label="@(_isEditMode ? "새 비밀번호 (미입력시 유지)" : "비밀번호")"
|
||||
InputType="InputType.Password" Required="@(!_isEditMode)" RequiredError="비밀번호를 입력해 주세요." Class="mb-3" />
|
||||
|
||||
<MudSelect T="string" @bind-Value="_formModel.Role" Label="역할 권한" Required="true" Class="mb-3">
|
||||
<MudSelectItem Value="@("Admin")">Admin (관리자)</MudSelectItem>
|
||||
<MudSelectItem Value="@("Operator")">Operator (운영자)</MudSelectItem>
|
||||
<MudSelectItem Value="@("Viewer")">Viewer (조회자)</MudSelectItem>
|
||||
</MudSelect>
|
||||
|
||||
@if (_isEditMode)
|
||||
{
|
||||
<MudSwitch T="bool" @bind-Value="_formModel.IsActive" Color="Color.Success" Label="계정 활성화 상태" />
|
||||
}
|
||||
</MudForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="CloseDialog" Class="px-5">취소</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveUser" Class="px-5">저장</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<UserDto> _users = new();
|
||||
private string SearchQuery = "";
|
||||
private bool _dialogVisible;
|
||||
private bool _isEditMode;
|
||||
private MudForm _form = new();
|
||||
private UserFormModel _formModel = new();
|
||||
private DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true };
|
||||
|
||||
private IEnumerable<UserDto> FilteredUsers
|
||||
{
|
||||
get => string.IsNullOrEmpty(SearchQuery)
|
||||
? _users
|
||||
: _users.Where(u => u.Username.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadUsers();
|
||||
}
|
||||
|
||||
private async Task LoadUsers()
|
||||
{
|
||||
try
|
||||
{
|
||||
// BaseAddress is set to HostEnvironment.BaseAddress by DI in Client/Program.cs.
|
||||
// Never override it with a hardcoded port.
|
||||
var res = await Http.GetFromJsonAsync<List<UserDto>>("api/users");
|
||||
if (res != null)
|
||||
{
|
||||
_users = res;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"사용자 목록 로드 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenAddUserDialog()
|
||||
{
|
||||
_isEditMode = false;
|
||||
_formModel = new UserFormModel { Role = "Viewer", IsActive = true };
|
||||
_dialogVisible = true;
|
||||
}
|
||||
|
||||
private void EditUser(UserDto user)
|
||||
{
|
||||
_isEditMode = true;
|
||||
_formModel = new UserFormModel
|
||||
{
|
||||
Username = user.Username,
|
||||
Role = user.Role,
|
||||
IsActive = user.IsActive,
|
||||
Password = "" // Clear password field for security
|
||||
};
|
||||
_dialogVisible = true;
|
||||
}
|
||||
|
||||
private async Task DeleteUser(UserDto user)
|
||||
{
|
||||
bool? result = await DialogService.ShowMessageBoxAsync(
|
||||
"사용자 삭제",
|
||||
$"정말로 사용자 '{user.Username}' 계정을 비활성화하시겠습니까?",
|
||||
yesText: "비활성화", cancelText: "취소");
|
||||
|
||||
if (result == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await Http.DeleteAsync($"api/users?username={user.Username}");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Snackbar.Add("사용자 계정이 비활성화되었습니다.", Severity.Success);
|
||||
await LoadUsers();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("계정 비활성화 작업에 실패했습니다.", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"API 에러: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseDialog()
|
||||
{
|
||||
_dialogVisible = false;
|
||||
}
|
||||
|
||||
private async Task SaveUser()
|
||||
{
|
||||
await _form.Validate();
|
||||
if (!_form.IsValid) return;
|
||||
|
||||
try
|
||||
{
|
||||
HttpResponseMessage response;
|
||||
if (_isEditMode)
|
||||
{
|
||||
response = await Http.PutAsJsonAsync("api/users", _formModel);
|
||||
}
|
||||
else
|
||||
{
|
||||
response = await Http.PostAsJsonAsync("api/users", _formModel);
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Snackbar.Add("사용자 정보가 성공적으로 저장되었습니다.", Severity.Success);
|
||||
_dialogVisible = false;
|
||||
await LoadUsers();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
Snackbar.Add($"저장 실패: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"API 오류 발생: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatDate(string isoString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(isoString)) return "-";
|
||||
if (DateTime.TryParse(isoString, out var dt))
|
||||
{
|
||||
return dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
|
||||
}
|
||||
return isoString;
|
||||
}
|
||||
|
||||
public class UserDto
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public bool IsActive { get; set; }
|
||||
public string CreatedAt { get; set; } = string.Empty;
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class UserFormModel
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public string Role { get; set; } = "Viewer";
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using QuantEngine.Web.Client.Services;
|
||||
using QuantEngine.Web.Client.Infrastructure;
|
||||
using MudBlazor.Services;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
// Register LocalStorage for cross-platform session persistence
|
||||
builder.Services.AddScoped<LocalStorageService>();
|
||||
|
||||
// App State Service (RBAC & global state management)
|
||||
builder.Services.AddScoped<AppStateService>();
|
||||
|
||||
// Authentication setup in WebAssembly client
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
|
||||
|
||||
// MudBlazor Services (CRITICAL: Required for Interactive WebAssembly)
|
||||
builder.Services.AddMudServices();
|
||||
|
||||
// HttpClient register (API-First standard)
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||
builder.Services.AddScoped<ApiClient>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
||||
<ProjectReference Include="..\..\QuantEngine.Application\QuantEngine.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.0" />
|
||||
<PackageReference Include="MudBlazor" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,8 +0,0 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.NavigateTo("login");
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
|
||||
namespace QuantEngine.Web.Client.Services;
|
||||
|
||||
public class ApiClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<ApiClient> _logger;
|
||||
public ApiClient(HttpClient http, ILogger<ApiClient> logger)
|
||||
{
|
||||
_http = http;
|
||||
// BaseAddress is set by the DI registration in Client/Program.cs via
|
||||
// builder.HostEnvironment.BaseAddress — never hardcode a port here.
|
||||
if (_http.BaseAddress == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ApiClient: HttpClient.BaseAddress is null. " +
|
||||
"Ensure the HttpClient is registered with HostEnvironment.BaseAddress in Client/Program.cs.");
|
||||
}
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// Collection API Methods
|
||||
|
||||
public async Task<CollectionDashboardStateDto?> GetCollectionStateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<CollectionDashboardStateDto>("api/collection/state");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching collection state");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CollectionRunsResponse?> GetCollectionRunsAsync(int limit = 20)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<CollectionRunsResponse>($"api/collection/runs?limit={limit}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching collection runs");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CollectionRunSnapshotsResponse?> GetCollectionSnapshotsAsync(string runId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<CollectionRunSnapshotsResponse>($"api/collection/runs/{runId}/snapshots");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Error fetching snapshots for run {runId}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CollectionRunErrorsResponse?> GetCollectionErrorsAsync(string runId, int limit = 50)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<CollectionRunErrorsResponse>($"api/collection/runs/{runId}/errors?limit={limit}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Error fetching errors for run {runId}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CollectionRunStartResponse?> StartCollectionRunAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/collection/run", new { });
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadFromJsonAsync<CollectionRunStartResponse>();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting collection run");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
|
||||
public class CollectionDashboardStateDto
|
||||
{
|
||||
[JsonPropertyName("lastRunId")]
|
||||
public string? LastRunId { get; set; }
|
||||
|
||||
[JsonPropertyName("lastRunStatus")]
|
||||
public string? LastRunStatus { get; set; }
|
||||
|
||||
[JsonPropertyName("lastFinishedAt")]
|
||||
public string? LastFinishedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("totalSnapshots")]
|
||||
public int TotalSnapshots { get; set; }
|
||||
|
||||
[JsonPropertyName("totalErrors")]
|
||||
public int TotalErrors { get; set; }
|
||||
|
||||
[JsonPropertyName("recentErrors")]
|
||||
public List<CollectionErrorDto> RecentErrors { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CollectionRunDto
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public string StartedAt { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("finishedAt")]
|
||||
public string? FinishedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("totalSnapshots")]
|
||||
public int? TotalSnapshots { get; set; }
|
||||
|
||||
[JsonPropertyName("totalErrors")]
|
||||
public int? TotalErrors { get; set; }
|
||||
}
|
||||
|
||||
public class CollectionSnapshotDto
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("datasetName")]
|
||||
public string DatasetName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("ticker")]
|
||||
public string Ticker { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("sourceName")]
|
||||
public string SourceName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("capturedAt")]
|
||||
public string CapturedAt { get; set; } = "";
|
||||
}
|
||||
|
||||
public class CollectionErrorDto
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("sourceName")]
|
||||
public string SourceName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("errorKind")]
|
||||
public string ErrorKind { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string ErrorMessage { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("ticker")]
|
||||
public string Ticker { get; set; } = "";
|
||||
}
|
||||
|
||||
public class CollectionRunsResponse
|
||||
{
|
||||
[JsonPropertyName("runs")]
|
||||
public List<CollectionRunDto> Runs { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
public class CollectionRunSnapshotsResponse
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("snapshots")]
|
||||
public List<CollectionSnapshotDto> Snapshots { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
public class CollectionRunErrorsResponse
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public List<CollectionErrorDto> Errors { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
public class CollectionRunStartResponse
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public string StartedAt { get; set; } = "";
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
namespace QuantEngine.Web.Client.Services;
|
||||
|
||||
public class AppStateService
|
||||
{
|
||||
private UserContext _currentUser;
|
||||
private List<string> _userRoles = new();
|
||||
private bool _isInitialized = false;
|
||||
|
||||
public event Action OnStateChanged;
|
||||
|
||||
public UserContext CurrentUser
|
||||
{
|
||||
get => _currentUser;
|
||||
set
|
||||
{
|
||||
_currentUser = value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public List<string> UserRoles
|
||||
{
|
||||
get => _userRoles;
|
||||
set
|
||||
{
|
||||
_userRoles = value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsInitialized
|
||||
{
|
||||
get => _isInitialized;
|
||||
set
|
||||
{
|
||||
_isInitialized = value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public AppStateService()
|
||||
{
|
||||
_currentUser = new UserContext();
|
||||
_userRoles = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize app state from current user context
|
||||
/// </summary>
|
||||
public async Task InitializeAsync(HttpClient httpClient)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await httpClient.GetAsync("api/auth/user");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
// Parse user info (implement as needed)
|
||||
CurrentUser = new UserContext { Name = "Admin", Email = "admin@quantengine.local" };
|
||||
UserRoles = new List<string> { "Admin" };
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Handle error
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if user has specific role (RBAC)
|
||||
/// </summary>
|
||||
public bool HasRole(string role)
|
||||
{
|
||||
return UserRoles.Contains(role);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if user has any of the specified roles
|
||||
/// </summary>
|
||||
public bool HasAnyRole(params string[] roles)
|
||||
{
|
||||
return roles.Any(r => UserRoles.Contains(r));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if user has all specified roles
|
||||
/// </summary>
|
||||
public bool HasAllRoles(params string[] roles)
|
||||
{
|
||||
return roles.All(r => UserRoles.Contains(r));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear user state
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
CurrentUser = new UserContext();
|
||||
UserRoles = new List<string>();
|
||||
IsInitialized = false;
|
||||
}
|
||||
|
||||
private void NotifyStateChanged() => OnStateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User context model
|
||||
/// </summary>
|
||||
public class UserContext
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API Response wrapper
|
||||
/// </summary>
|
||||
public class ApiResponse<T>
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; }
|
||||
public T Data { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pagination model
|
||||
/// </summary>
|
||||
public class PaginatedResponse<T>
|
||||
{
|
||||
public List<T> Items { get; set; }
|
||||
public int PageNumber { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
public int TotalPages => (TotalCount + PageSize - 1) / PageSize;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using Microsoft.JSInterop;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QuantEngine.Web.Client.Services
|
||||
{
|
||||
public class LocalStorageService
|
||||
{
|
||||
private readonly IJSRuntime _js;
|
||||
|
||||
public LocalStorageService(IJSRuntime js)
|
||||
{
|
||||
_js = js;
|
||||
}
|
||||
|
||||
public async Task SetAsync<T>(string key, T value)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value);
|
||||
await _js.InvokeVoidAsync("localStorage.setItem", key, json);
|
||||
}
|
||||
|
||||
public async Task<T?> GetAsync<T>(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await _js.InvokeAsync<string?>("localStorage.getItem", key);
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string key)
|
||||
{
|
||||
await _js.InvokeVoidAsync("localStorage.removeItem", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
using MudBlazor;
|
||||
|
||||
namespace QuantEngine.Web.Client.Theme;
|
||||
|
||||
public static class AppTheme
|
||||
{
|
||||
public static MudTheme LightTheme => new()
|
||||
{
|
||||
PaletteLight = new PaletteLight
|
||||
{
|
||||
Primary = "#3f51b5",
|
||||
Secondary = "#f50057",
|
||||
Success = "#4caf50",
|
||||
Warning = "#ff9800",
|
||||
Error = "#f44336",
|
||||
Info = "#2196f3",
|
||||
Dark = "#121212",
|
||||
Background = "#fafafa",
|
||||
Surface = "#ffffff",
|
||||
TextPrimary = "#212121",
|
||||
TextSecondary = "rgba(0,0,0,0.6)",
|
||||
DrawerBackground = "#ffffff",
|
||||
DrawerText = "#212121",
|
||||
AppbarBackground = "#3f51b5",
|
||||
AppbarText = "#ffffff",
|
||||
ActionDefault = "#c0c0c0",
|
||||
ActionDisabled = "#f5f5f5",
|
||||
ActionDisabledBackground = "rgba(0,0,0,0.12)",
|
||||
Divider = "#e0e0e0",
|
||||
DividerLight = "#f5f5f5",
|
||||
TableLines = "#e0e0e0",
|
||||
LinesDefault = "#e0e0e0",
|
||||
LinesInputs = "#bdbdbd",
|
||||
TextDisabled = "rgba(0,0,0,0.38)"
|
||||
},
|
||||
Typography = new Typography
|
||||
{
|
||||
Default = new DefaultTypography
|
||||
{
|
||||
FontFamily = new[] { "Roboto", "sans-serif" },
|
||||
FontSize = "1rem",
|
||||
FontWeight = "400",
|
||||
LineHeight = "1.5",
|
||||
LetterSpacing = "0.5px"
|
||||
},
|
||||
H1 = new H1Typography
|
||||
{
|
||||
FontSize = "6rem",
|
||||
FontWeight = "300",
|
||||
LineHeight = "1.167",
|
||||
LetterSpacing = "-0.015625em"
|
||||
},
|
||||
H2 = new H2Typography
|
||||
{
|
||||
FontSize = "3.75rem",
|
||||
FontWeight = "300",
|
||||
LineHeight = "1.2",
|
||||
LetterSpacing = "-0.0083333333em"
|
||||
},
|
||||
H3 = new H3Typography
|
||||
{
|
||||
FontSize = "3rem",
|
||||
FontWeight = "400",
|
||||
LineHeight = "1.167",
|
||||
LetterSpacing = "0em"
|
||||
},
|
||||
H4 = new H4Typography
|
||||
{
|
||||
FontSize = "2.125rem",
|
||||
FontWeight = "500",
|
||||
LineHeight = "1.235",
|
||||
LetterSpacing = "0.0125em"
|
||||
},
|
||||
H5 = new H5Typography
|
||||
{
|
||||
FontSize = "1.5rem",
|
||||
FontWeight = "500",
|
||||
LineHeight = "1.334",
|
||||
LetterSpacing = "0em"
|
||||
},
|
||||
H6 = new H6Typography
|
||||
{
|
||||
FontSize = "1.25rem",
|
||||
FontWeight = "600",
|
||||
LineHeight = "1.6",
|
||||
LetterSpacing = "0.0125em"
|
||||
},
|
||||
Body1 = new Body1Typography
|
||||
{
|
||||
FontSize = "1rem",
|
||||
FontWeight = "500",
|
||||
LineHeight = "1.5",
|
||||
LetterSpacing = "0.03125em"
|
||||
},
|
||||
Body2 = new Body2Typography
|
||||
{
|
||||
FontSize = "0.875rem",
|
||||
FontWeight = "400",
|
||||
LineHeight = "1.43",
|
||||
LetterSpacing = "0.0178571429em"
|
||||
},
|
||||
Button = new ButtonTypography
|
||||
{
|
||||
FontSize = "0.875rem",
|
||||
FontWeight = "600",
|
||||
LineHeight = "1.75",
|
||||
LetterSpacing = "0.0892857143em"
|
||||
},
|
||||
Caption = new CaptionTypography
|
||||
{
|
||||
FontSize = "0.75rem",
|
||||
FontWeight = "400",
|
||||
LineHeight = "1.66",
|
||||
LetterSpacing = "0.0333333333em"
|
||||
}
|
||||
},
|
||||
LayoutProperties = new LayoutProperties
|
||||
{
|
||||
DefaultBorderRadius = "4px",
|
||||
DrawerWidthLeft = "256px",
|
||||
DrawerWidthRight = "256px",
|
||||
AppbarHeight = "64px",
|
||||
}
|
||||
};
|
||||
|
||||
public static MudTheme DarkTheme => new()
|
||||
{
|
||||
PaletteDark = new PaletteDark
|
||||
{
|
||||
Primary = "#bb86fc",
|
||||
Secondary = "#03dac6",
|
||||
Success = "#4caf50",
|
||||
Warning = "#ff9800",
|
||||
Error = "#cf6679",
|
||||
Info = "#2196f3",
|
||||
Dark = "#121212",
|
||||
Background = "#121212",
|
||||
Surface = "#1e1e1e",
|
||||
TextPrimary = "#ffffff",
|
||||
TextSecondary = "rgba(255,255,255,0.7)",
|
||||
DrawerBackground = "#1e1e1e",
|
||||
DrawerText = "#ffffff",
|
||||
AppbarBackground = "#1f1f1f",
|
||||
AppbarText = "#ffffff",
|
||||
ActionDefault = "#3f3f3f",
|
||||
ActionDisabled = "#1e1e1e",
|
||||
ActionDisabledBackground = "rgba(255,255,255,0.12)",
|
||||
Divider = "#37474f",
|
||||
DividerLight = "#2c3e50",
|
||||
TableLines = "#37474f",
|
||||
LinesDefault = "#37474f",
|
||||
LinesInputs = "#555555",
|
||||
TextDisabled = "rgba(255,255,255,0.38)"
|
||||
},
|
||||
Typography = LightTheme.Typography,
|
||||
LayoutProperties = LightTheme.LayoutProperties
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using MudBlazor
|
||||
@using QuantEngine.Web.Client
|
||||
@using QuantEngine.Web.Client.Pages
|
||||
@using QuantEngine.Web.Client.Layout
|
||||
@using QuantEngine.Web.Client.Infrastructure
|
||||
@using QuantEngine.Web.Client.Services
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@@ -1,297 +0,0 @@
|
||||
/* QuantEngine Global Styles */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--mud-palette-text-primary, #212121);
|
||||
background-color: var(--mud-palette-background, #fafafa);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--mud-palette-surface, #ffffff);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--mud-palette-action-default, #c0c0c0);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--mud-palette-primary, #3f51b5);
|
||||
}
|
||||
|
||||
/* Text Utilities */
|
||||
.text-primary {
|
||||
color: var(--mud-palette-primary, #3f51b5);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--mud-palette-secondary, #f50057);
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--mud-palette-success, #4caf50);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--mud-palette-warning, #ff9800);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--mud-palette-error, #f44336);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--mud-palette-text-secondary, rgba(0,0,0,0.6));
|
||||
}
|
||||
|
||||
/* Spacing Utilities */
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-3 { margin-top: 1rem; }
|
||||
.mt-4 { margin-top: 1.5rem; }
|
||||
.mt-5 { margin-top: 3rem; }
|
||||
|
||||
.mb-1 { margin-bottom: 0.25rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 1rem; }
|
||||
.mb-4 { margin-bottom: 1.5rem; }
|
||||
.mb-5 { margin-bottom: 3rem; }
|
||||
|
||||
.mx-auto { margin-left: auto; margin-right: auto; }
|
||||
.my-auto { margin-top: auto; margin-bottom: auto; }
|
||||
|
||||
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
|
||||
|
||||
/* Flex Utilities */
|
||||
.d-flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.align-items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-content-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-content-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Gap Utilities */
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-3 { gap: 1rem; }
|
||||
.gap-4 { gap: 1.5rem; }
|
||||
|
||||
/* Loading Skeleton */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--mud-palette-surface, #fff) 0%,
|
||||
var(--mud-palette-divider, #e0e0e0) 50%,
|
||||
var(--mud-palette-surface, #fff) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* MudBlazor Overrides */
|
||||
.mud-appbar {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mud-drawer {
|
||||
border-right: 1px solid var(--mud-palette-divider, #e0e0e0);
|
||||
}
|
||||
|
||||
.mud-drawer-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mud-nav-link {
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.25rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mud-nav-link:hover {
|
||||
background-color: var(--mud-palette-action-default-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.mud-nav-link.mud-ripple-nav-link-active {
|
||||
background-color: var(--mud-palette-primary-lighten, rgba(63, 81, 181, 0.1));
|
||||
color: var(--mud-palette-primary, #3f51b5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mud-card {
|
||||
border: 1px solid var(--mud-palette-divider, #e0e0e0);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.mud-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.mud-button {
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mud-button-root:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.mud-input-control {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mud-input-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mud-input {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mud-input.mud-input-text {
|
||||
background-color: var(--mud-palette-surface, #ffffff);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.mud-table {
|
||||
background-color: var(--mud-palette-surface, #ffffff);
|
||||
}
|
||||
|
||||
.mud-table-head {
|
||||
background-color: var(--mud-palette-background, #fafafa);
|
||||
}
|
||||
|
||||
.mud-table-row:hover {
|
||||
background-color: var(--mud-palette-action-default-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.mud-table-cell {
|
||||
padding: 1rem;
|
||||
border-color: var(--mud-palette-divider, #e0e0e0);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mud-drawer {
|
||||
width: 100% !important;
|
||||
max-width: 90% !important;
|
||||
}
|
||||
|
||||
.mud-appbar {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.mud-table-cell {
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation Classes */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.slide-in {
|
||||
animation: slideIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
.mud-appbar,
|
||||
.mud-drawer,
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using FastEndpoints;
|
||||
using Hangfire;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
|
||||
namespace QuantEngine.Web.Endpoints;
|
||||
@@ -214,20 +216,52 @@ public class GetLatestSnapshotsEndpoint : Endpoint<GetLatestSnapshotsRequest, Ge
|
||||
}
|
||||
}
|
||||
|
||||
public class StartCollectionRunEndpoint : EndpointWithoutRequest
|
||||
public class StartCollectionRunResponse
|
||||
{
|
||||
public string RunId { get; set; } = "";
|
||||
}
|
||||
|
||||
public class StartCollectionRunEndpoint : EndpointWithoutRequest<StartCollectionRunResponse>
|
||||
{
|
||||
private readonly IBackgroundJobClient _jobClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<StartCollectionRunEndpoint> _logger;
|
||||
|
||||
public StartCollectionRunEndpoint(
|
||||
IBackgroundJobClient jobClient,
|
||||
IConfiguration configuration,
|
||||
ILogger<StartCollectionRunEndpoint> logger)
|
||||
{
|
||||
_jobClient = jobClient;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/collection/run");
|
||||
AllowAnonymous();
|
||||
Description(d => d
|
||||
.Produces(202)
|
||||
.Produces<StartCollectionRunResponse>(202)
|
||||
.Produces(500));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
// Return 202 Accepted status code via generic status code handler
|
||||
await SendResultAsync(Microsoft.AspNetCore.Http.Results.Accepted());
|
||||
try
|
||||
{
|
||||
var runId = $"api-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
|
||||
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" }.ToList();
|
||||
|
||||
_jobClient.Enqueue<ICollectionOrchestrator>(o => o.RunCollectionAsync(runId, accountMode, tickers));
|
||||
|
||||
_logger.LogInformation("Collection run {RunId} enqueued", runId);
|
||||
|
||||
await SendAsync(new StartCollectionRunResponse { RunId = runId }, 202, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await SendErrorsAsync(500, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,7 @@
|
||||
|
||||
<div class="login-footer">
|
||||
<p>© 2026 QuantEngine. 모든 권리 예약.</p>
|
||||
<p style="font-size: 11px; margin-top: 4px; opacity: 0.8;">Version: @Model.AppVersion</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -17,6 +18,64 @@ public class LoginModel : PageModel
|
||||
public string? Username { get; set; }
|
||||
public bool RememberUsername { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string AppVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
var rawVersion = Assembly.GetEntryAssembly()
|
||||
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion;
|
||||
|
||||
if (!string.IsNullOrEmpty(rawVersion) && rawVersion.StartsWith("quant_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return rawVersion;
|
||||
}
|
||||
|
||||
var dateStr = DateTime.UtcNow.AddHours(9).ToString("yyyyMMdd");
|
||||
string gitHash = "c6f269e";
|
||||
try
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var current = new DirectoryInfo(baseDir);
|
||||
string? repoRoot = null;
|
||||
while (current != null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
|
||||
{
|
||||
repoRoot = current.FullName;
|
||||
break;
|
||||
}
|
||||
current = current.Parent;
|
||||
}
|
||||
if (repoRoot != null)
|
||||
{
|
||||
var headPath = Path.Combine(repoRoot, ".git", "HEAD");
|
||||
if (System.IO.File.Exists(headPath))
|
||||
{
|
||||
var headContent = System.IO.File.ReadAllText(headPath).Trim();
|
||||
if (headContent.StartsWith("ref:"))
|
||||
{
|
||||
var refPath = Path.Combine(repoRoot, ".git", headContent.Substring(4).Trim().Replace('/', Path.DirectorySeparatorChar));
|
||||
if (System.IO.File.Exists(refPath))
|
||||
{
|
||||
var fullHash = System.IO.File.ReadAllText(refPath).Trim();
|
||||
if (fullHash.Length >= 7)
|
||||
gitHash = fullHash.Substring(0, 7);
|
||||
}
|
||||
}
|
||||
else if (headContent.Length >= 7)
|
||||
{
|
||||
gitHash = headContent.Substring(0, 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
|
||||
int runCount = 11;
|
||||
return $"quant_{dateStr}.{runCount}.{gitHash}";
|
||||
}
|
||||
}
|
||||
|
||||
public LoginModel(AuthService authService, IIpLockoutService lockoutService, ILogger<LoginModel> logger)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "{runId}"
|
||||
@page "{runId?}"
|
||||
@model QuantEngine.Web.Pages.Admin.Collection.DetailModel
|
||||
@{
|
||||
ViewData["Title"] = "수집 실행 상세";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Web.Services;
|
||||
@@ -19,16 +20,31 @@ public class DetailModel : PageModel
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync(string runId)
|
||||
public async Task OnGetAsync([FromQuery] string? runId, [FromRoute] string? routeRunId)
|
||||
{
|
||||
var targetRunId = runId ?? routeRunId;
|
||||
if (string.IsNullOrEmpty(targetRunId))
|
||||
{
|
||||
if (RouteData.Values.TryGetValue("runId", out var routeVal))
|
||||
{
|
||||
targetRunId = routeVal?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(targetRunId))
|
||||
{
|
||||
_logger.LogWarning("Detail page loaded without a runId");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runs = await _collectionRepository.GetRecentRunsAsync(limit: 100);
|
||||
Run = runs.FirstOrDefault(r => r.RunId == runId);
|
||||
Run = runs.FirstOrDefault(r => r.RunId == targetRunId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load collection run detail");
|
||||
_logger.LogError(ex, "Failed to load collection run detail for runId: {RunId}", targetRunId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
@page
|
||||
@model QuantEngine.Web.Pages.Admin.Collection.StartModel
|
||||
@{
|
||||
ViewData["Title"] = "수집 시작";
|
||||
}
|
||||
|
||||
<div class="page-header d-print-none">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<h2 class="page-title">데이터 수집 시작</h2>
|
||||
<div class="text-muted">Hangfire 백그라운드 작업으로 수집을 시작합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@Model.ErrorMessage</div>
|
||||
<a href="/Admin/Collection" class="btn btn-secondary">목록으로</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-success">수집 작업이 등록되었습니다. 실행 ID: <code>@Model.RunId</code></div>
|
||||
<a href="/Admin/Collection/Detail?runId=@Uri.EscapeDataString(Model.RunId!)" class="btn btn-primary">실행 상세 보기</a>
|
||||
<a href="/Admin/Collection" class="btn btn-secondary">목록으로</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
using Hangfire;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using QuantEngine.Web.Services;
|
||||
|
||||
namespace QuantEngine.Web.Pages.Admin.Collection;
|
||||
|
||||
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||
public class StartModel : PageModel
|
||||
{
|
||||
private readonly IBackgroundJobClient _jobClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<StartModel> _logger;
|
||||
|
||||
public string? RunId { get; private set; }
|
||||
public string? ErrorMessage { get; private set; }
|
||||
|
||||
public StartModel(
|
||||
IBackgroundJobClient jobClient,
|
||||
IConfiguration configuration,
|
||||
ILogger<StartModel> logger)
|
||||
{
|
||||
_jobClient = jobClient;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
try
|
||||
{
|
||||
RunId = $"admin-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
|
||||
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" }.ToList();
|
||||
|
||||
_jobClient.Enqueue<ICollectionOrchestrator>(orchestrator =>
|
||||
orchestrator.RunCollectionAsync(RunId!, accountMode, tickers));
|
||||
|
||||
_logger.LogInformation("Collection run {RunId} enqueued from admin page", RunId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to enqueue collection run from admin page");
|
||||
ErrorMessage = "수집 작업을 등록하지 못했습니다. Hangfire 상태와 서버 로그를 확인하세요.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,10 +87,14 @@
|
||||
<span class="text-muted">환경:</span>
|
||||
<strong>@Model.EnvironmentName</strong>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-2">
|
||||
<span class="text-muted">데이터베이스:</span>
|
||||
<strong>@(Model.IsDatabaseConnected ? "연결됨" : "연결 끊김")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted">배포 버전:</span>
|
||||
<strong>@Model.AppVersion</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
@@ -17,6 +19,64 @@ public class IndexModel : PageModel
|
||||
public int? RecentRunsCount { get; set; }
|
||||
public bool IsDatabaseConnected { get; set; }
|
||||
public string EnvironmentName => _environment.EnvironmentName;
|
||||
public string AppVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
var rawVersion = Assembly.GetEntryAssembly()
|
||||
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion;
|
||||
|
||||
if (!string.IsNullOrEmpty(rawVersion) && rawVersion.StartsWith("quant_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return rawVersion;
|
||||
}
|
||||
|
||||
var dateStr = DateTime.UtcNow.AddHours(9).ToString("yyyyMMdd");
|
||||
string gitHash = "c6f269e";
|
||||
try
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var current = new DirectoryInfo(baseDir);
|
||||
string? repoRoot = null;
|
||||
while (current != null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
|
||||
{
|
||||
repoRoot = current.FullName;
|
||||
break;
|
||||
}
|
||||
current = current.Parent;
|
||||
}
|
||||
if (repoRoot != null)
|
||||
{
|
||||
var headPath = Path.Combine(repoRoot, ".git", "HEAD");
|
||||
if (System.IO.File.Exists(headPath))
|
||||
{
|
||||
var headContent = System.IO.File.ReadAllText(headPath).Trim();
|
||||
if (headContent.StartsWith("ref:"))
|
||||
{
|
||||
var refPath = Path.Combine(repoRoot, ".git", headContent.Substring(4).Trim().Replace('/', Path.DirectorySeparatorChar));
|
||||
if (System.IO.File.Exists(refPath))
|
||||
{
|
||||
var fullHash = System.IO.File.ReadAllText(refPath).Trim();
|
||||
if (fullHash.Length >= 7)
|
||||
gitHash = fullHash.Substring(0, 7);
|
||||
}
|
||||
}
|
||||
else if (headContent.Length >= 7)
|
||||
{
|
||||
gitHash = headContent.Substring(0, 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
|
||||
int runCount = 11;
|
||||
return $"quant_{dateStr}.{runCount}.{gitHash}";
|
||||
}
|
||||
}
|
||||
|
||||
public IndexModel(
|
||||
IWorkspaceRepository workspaceRepository,
|
||||
|
||||
@@ -16,6 +16,18 @@
|
||||
</div>
|
||||
|
||||
<div class="page-body">
|
||||
@if (TempData["SuccessMessage"] != null)
|
||||
{
|
||||
<div class="alert alert-success" role="alert" style="background-color: rgba(76, 175, 80, 0.15); border: 1px solid rgba(76, 175, 80, 0.3); color: #81c784; padding: 12px; border-radius: 6px; margin-bottom: 16px;">
|
||||
@TempData["SuccessMessage"]
|
||||
</div>
|
||||
}
|
||||
@if (TempData["ErrorMessage"] != null)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert" style="background-color: rgba(244, 67, 54, 0.15); border: 1px solid rgba(244, 67, 54, 0.3); color: #ff7675; padding: 12px; border-radius: 6px; margin-bottom: 16px;">
|
||||
@TempData["ErrorMessage"]
|
||||
</div>
|
||||
}
|
||||
<div class="row row-deck row-cards">
|
||||
<!-- 예약된 작업 -->
|
||||
<div class="col-12">
|
||||
@@ -56,7 +68,12 @@
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<a href="javascript:void(0)" class="btn btn-sm btn-link">수정</a>
|
||||
<form method="post" asp-page-handler="TriggerJob" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="jobId" value="@job.JobId" />
|
||||
<button type="submit" class="btn btn-sm btn-success text-white">즉시 실행</button>
|
||||
</form>
|
||||
<a href="javascript:void(0)" class="btn btn-sm btn-link ms-2">수정</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Hangfire;
|
||||
using Hangfire.Storage;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using QuantEngine.Web.Services;
|
||||
|
||||
@@ -32,6 +33,29 @@ public class IndexModel : PageModel
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostTriggerJobAsync(string jobId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(jobId))
|
||||
{
|
||||
TempData["ErrorMessage"] = "올바르지 않은 작업 ID입니다.";
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RecurringJob.TriggerJob(jobId);
|
||||
TempData["SuccessMessage"] = $"작업 '{DescribeJobId(jobId)}'이(가) 즉시 실행 큐에 등록되었습니다.";
|
||||
_logger.LogInformation("Manually triggered Hangfire recurring job: {JobId}", jobId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to trigger Hangfire job: {JobId}", jobId);
|
||||
TempData["ErrorMessage"] = $"작업 실행 실패: {ex.Message}";
|
||||
}
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
private void LoadOperationsData()
|
||||
{
|
||||
try
|
||||
@@ -51,6 +75,7 @@ public class IndexModel : PageModel
|
||||
var recurringJobs = connection.GetRecurringJobs();
|
||||
ScheduledJobs = recurringJobs
|
||||
.Select(j => new ScheduledJobInfo(
|
||||
j.Id,
|
||||
DescribeJobId(j.Id),
|
||||
DescribeCron(j.Cron),
|
||||
j.NextExecution,
|
||||
@@ -108,7 +133,7 @@ public class IndexModel : PageModel
|
||||
private static string DescribeCron(string cron) => cron switch
|
||||
{
|
||||
"0 9 * * *" => "매일 09:00",
|
||||
"0 9-15 * * 1-5" => "평일 09-15시 매시",
|
||||
"0 9,11,13,15 * * 1-5" => "평일 2시간 단위 (09:00~15:00)",
|
||||
"0 17 * * 5" => "매주 금요일 17:00",
|
||||
"0 2 1 * *" => "매월 1일 02:00",
|
||||
_ => cron,
|
||||
@@ -127,5 +152,5 @@ public class IndexModel : PageModel
|
||||
};
|
||||
}
|
||||
|
||||
public record ScheduledJobInfo(string JobName, string Schedule, DateTime? NextRun, bool IsEnabled);
|
||||
public record ScheduledJobInfo(string JobId, string JobName, string Schedule, DateTime? NextRun, bool IsEnabled);
|
||||
public record JobExecutionInfo(string JobName, DateTime StartedAt, DateTime? CompletedAt, bool IsSuccess);
|
||||
|
||||
@@ -53,11 +53,9 @@
|
||||
</td>
|
||||
<td>@(user.CreatedAt ?? "-")</td>
|
||||
<td>
|
||||
<a href="/Admin/Users/@user.Username/Edit" class="btn btn-sm btn-link">수정</a>
|
||||
<form method="post" style="display:inline;" onsubmit="return confirm('이 사용자를 비활성화하시겠습니까?');">
|
||||
<a asp-page="./Edit" asp-route-username="@user.Username" class="btn btn-sm btn-link">수정</a>
|
||||
<form method="post" asp-page-handler="Delete" asp-route-username="@user.Username" style="display:inline;" onsubmit="return confirm('이 사용자를 비활성화하시겠습니까?');">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="handler" value="delete" />
|
||||
<input type="hidden" name="username" value="@user.Username" />
|
||||
<button type="submit" class="btn btn-sm btn-link text-danger">비활성화</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
using QuantEngine.Infrastructure.Repositories;
|
||||
using QuantEngine.Infrastructure.Services;
|
||||
@@ -22,6 +23,22 @@ try
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
// Data Protection: without this, ASP.NET Core derives its key-ring
|
||||
// discriminator from the app's physical content root path. Every
|
||||
// deployment lands in a brand-new directory
|
||||
// (~/deployments/quantengine_{tag}_{hash}/), so the discriminator
|
||||
// changed on every single deploy and every previously-issued auth
|
||||
// cookie became undecryptable -- forcing all users to log in again
|
||||
// after each release. SetApplicationName pins a stable discriminator;
|
||||
// PersistKeysToFileSystem points at a location outside the versioned
|
||||
// deployment folders so the actual key material also survives restarts.
|
||||
var dataProtectionKeysPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"quantengine-keys");
|
||||
builder.Services.AddDataProtection()
|
||||
.SetApplicationName("QuantEngine")
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath));
|
||||
|
||||
// Authentication & Authorization
|
||||
builder.Services.AddAuthentication(opts =>
|
||||
{
|
||||
@@ -79,12 +96,23 @@ try
|
||||
// Repository Services
|
||||
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
|
||||
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
||||
builder.Services.AddScoped<INormalizedLearningStore, NormalizedLearningStore>();
|
||||
builder.Services.AddScoped<ILearningDatasetReader, LearningDatasetReader>();
|
||||
builder.Services.AddScoped<DecisionLearningService>();
|
||||
builder.Services.AddScoped<LearningDatasetService>();
|
||||
builder.Services.AddSingleton<GatherTradingDataParser>();
|
||||
builder.Services.AddScoped<JsonSeedIngestionService>();
|
||||
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
||||
builder.Services.AddScoped<HistoryIngestionService>();
|
||||
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
|
||||
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
||||
builder.Services.AddHttpClient<IKisApiClient, KisApiClient>();
|
||||
|
||||
// Collection Pipeline Services
|
||||
builder.Services.AddScoped<SourcePriorityResolver>();
|
||||
builder.Services.AddScoped<PriceDataNormalizer>();
|
||||
builder.Services.AddScoped<ICollectionOrchestrator, KisDataCollectionOrchestrator>();
|
||||
|
||||
// Hangfire Background Jobs
|
||||
try
|
||||
{
|
||||
|
||||
@@ -18,25 +18,10 @@
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Exclude client project files from server build to avoid duplicate compilations -->
|
||||
<!-- BUT preserve Client\wwwroot for static web assets -->
|
||||
<Compile Remove="Client\**" />
|
||||
<EmbeddedResource Remove="Client\**" />
|
||||
<None Remove="Client\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Only remove non-wwwroot Client content -->
|
||||
<Content Remove="Client\**" />
|
||||
<Content Include="Client\wwwroot\**" CopyToPublishDirectory="Never" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -9,9 +9,10 @@ public interface IIpLockoutService
|
||||
|
||||
public class IpLockoutService : IIpLockoutService
|
||||
{
|
||||
private readonly Dictionary<string, (int Attempts, DateTime LockedUntil)> _attemptLog = [];
|
||||
private readonly Dictionary<string, (int Attempts, int LockoutCount, DateTime LockedUntil)> _attemptLog = [];
|
||||
private const int MaxFailedAttempts = 3;
|
||||
private const int LockoutDurationMinutes = 15;
|
||||
private const int MaxLockoutDurationMinutes = 1440; // 24 hours cap
|
||||
private readonly object _lock = new();
|
||||
|
||||
public bool IsLockedOut(string ipAddress)
|
||||
@@ -23,7 +24,11 @@ public class IpLockoutService : IIpLockoutService
|
||||
if (DateTime.UtcNow < record.LockedUntil)
|
||||
return true;
|
||||
|
||||
_attemptLog.Remove(ipAddress);
|
||||
// Reset failed attempts but keep the LockoutCount to calculate progressive delay on next failure
|
||||
if (record.Attempts >= MaxFailedAttempts)
|
||||
{
|
||||
_attemptLog[ipAddress] = (0, record.LockoutCount, DateTime.MinValue);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -39,14 +44,19 @@ public class IpLockoutService : IIpLockoutService
|
||||
record.Attempts++;
|
||||
if (record.Attempts >= MaxFailedAttempts)
|
||||
{
|
||||
record.LockedUntil = DateTime.UtcNow.AddMinutes(LockoutDurationMinutes);
|
||||
// Calculate exponential progressive lockout duration: 15 * 2^LockoutCount
|
||||
var durationMinutes = LockoutDurationMinutes * Math.Pow(2, record.LockoutCount);
|
||||
durationMinutes = Math.Min(durationMinutes, MaxLockoutDurationMinutes);
|
||||
|
||||
record.LockedUntil = DateTime.UtcNow.AddMinutes(durationMinutes);
|
||||
record.LockoutCount++;
|
||||
}
|
||||
|
||||
_attemptLog[ipAddress] = record;
|
||||
}
|
||||
else
|
||||
{
|
||||
_attemptLog[ipAddress] = (1, DateTime.UtcNow);
|
||||
_attemptLog[ipAddress] = (1, 0, DateTime.MinValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ using Hangfire.PostgreSql;
|
||||
using Hangfire.MemoryStorage;
|
||||
using System.Linq.Expressions;
|
||||
using QuantEngine.Application.Services;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace QuantEngine.Web.Services;
|
||||
|
||||
@@ -17,15 +19,21 @@ public class SchedulerService
|
||||
private readonly ILogger<SchedulerService> _logger;
|
||||
private readonly IBackgroundJobClient _jobClient;
|
||||
private readonly IRecurringJobManager _recurringJobManager;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public SchedulerService(
|
||||
ILogger<SchedulerService> logger,
|
||||
IBackgroundJobClient jobClient,
|
||||
IRecurringJobManager recurringJobManager)
|
||||
IRecurringJobManager recurringJobManager,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_jobClient = jobClient;
|
||||
_recurringJobManager = recurringJobManager;
|
||||
_scopeFactory = scopeFactory;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -45,11 +53,11 @@ public class SchedulerService
|
||||
new RecurringJobOptions { TimeZone = TimeZoneInfo.Local }
|
||||
);
|
||||
|
||||
// Hourly price update (during market hours 9 AM - 4 PM)
|
||||
// Hourly price update (during market hours 9 AM - 4 PM, every 2 hours)
|
||||
_recurringJobManager.AddOrUpdate(
|
||||
"hourly-price-update",
|
||||
() => UpdatePricesAsync(),
|
||||
"0 9-15 * * 1-5", // Every hour, 9 AM to 3 PM, Mon-Fri
|
||||
"0 9,11,13,15 * * 1-5", // 9:00, 11:00, 13:00, 15:00 on Mon-Fri
|
||||
new RecurringJobOptions { TimeZone = TimeZoneInfo.Local }
|
||||
);
|
||||
|
||||
@@ -89,14 +97,22 @@ public class SchedulerService
|
||||
// List of tickers to collect
|
||||
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" };
|
||||
|
||||
foreach (var ticker in tickers)
|
||||
{
|
||||
// Simulate data collection
|
||||
await Task.Delay(100);
|
||||
_logger.LogInformation("Collected data for ticker: {Ticker}", ticker);
|
||||
}
|
||||
// Create scope for scoped services
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var orchestrator = scope.ServiceProvider.GetRequiredService<ICollectionOrchestrator>();
|
||||
|
||||
_logger.LogInformation("Daily data collection completed at {Time}", DateTime.Now);
|
||||
// Build runId with timestamp
|
||||
var runId = $"daily-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||
|
||||
// Read account mode from configuration (default to "mock")
|
||||
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
|
||||
|
||||
// Execute collection
|
||||
var result = await orchestrator.RunCollectionAsync(runId, accountMode, tickers.ToList());
|
||||
|
||||
// Log completion
|
||||
_logger.LogInformation("Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors",
|
||||
runId, result.SuccessCount, result.ErrorCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -312,8 +328,7 @@ public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
|
||||
{
|
||||
public bool Authorize(DashboardContext context)
|
||||
{
|
||||
// TODO: Implement proper authorization check
|
||||
// For now, allow all in development
|
||||
return true;
|
||||
var httpContext = context.GetHttpContext();
|
||||
return httpContext.User.Identity?.IsAuthenticated == true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
2026-07-11 17:02:14.217 +09:00 [FTL] Application terminated unexpectedly
|
||||
System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: QuantEngine.Core.Interfaces.IKisApiClient Lifetime: Scoped ImplementationType: QuantEngine.Infrastructure.Services.KisApiClient': Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'QuantEngine.Infrastructure.Services.KisApiClient'.)
|
||||
---> System.InvalidOperationException: Error while validating the service descriptor 'ServiceType: QuantEngine.Core.Interfaces.IKisApiClient Lifetime: Scoped ImplementationType: QuantEngine.Infrastructure.Services.KisApiClient': Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'QuantEngine.Infrastructure.Services.KisApiClient'.
|
||||
---> System.InvalidOperationException: Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'QuantEngine.Infrastructure.Services.KisApiClient'.
|
||||
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateArgumentCallSites(ServiceIdentifier serviceIdentifier, Type implementationType, CallSiteChain callSiteChain, ParameterInfo[] parameters, Boolean throwIfCallSiteNotFound)
|
||||
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateConstructorCallSite(ResultCache lifetime, ServiceIdentifier serviceIdentifier, Type implementationType, CallSiteChain callSiteChain)
|
||||
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateExact(ServiceDescriptor descriptor, ServiceIdentifier serviceIdentifier, CallSiteChain callSiteChain, Int32 slot)
|
||||
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.GetCallSite(ServiceDescriptor serviceDescriptor, CallSiteChain callSiteChain)
|
||||
at Microsoft.Extensions.DependencyInjection.ServiceProvider.ValidateService(ServiceDescriptor descriptor)
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.Extensions.DependencyInjection.ServiceProvider.ValidateService(ServiceDescriptor descriptor)
|
||||
at Microsoft.Extensions.DependencyInjection.ServiceProvider..ctor(ICollection`1 serviceDescriptors, ServiceProviderOptions options)
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.Extensions.DependencyInjection.ServiceProvider..ctor(ICollection`1 serviceDescriptors, ServiceProviderOptions options)
|
||||
at Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(IServiceCollection services, ServiceProviderOptions options)
|
||||
at Microsoft.Extensions.Hosting.HostApplicationBuilder.Build()
|
||||
at Microsoft.AspNetCore.Builder.WebApplicationBuilder.Build()
|
||||
at Program.<Main>$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 99
|
||||
@@ -0,0 +1,381 @@
|
||||
2026-07-11 18:41:46.431 +09:00 [INF] Registered 10 endpoints in 164 milliseconds.
|
||||
2026-07-11 18:41:46.473 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||
2026-07-11 18:41:50.463 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:41:50.464 +09:00 [ERR] ❌ Database migration failed
|
||||
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||
2026-07-11 18:41:50.476 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:41:50.498 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||
2026-07-11 18:42:27.125 +09:00 [INF] Registered 10 endpoints in 178 milliseconds.
|
||||
2026-07-11 18:42:27.164 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||
2026-07-11 18:42:31.234 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:42:31.234 +09:00 [ERR] ❌ Database migration failed
|
||||
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||
2026-07-11 18:42:31.240 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:42:31.281 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||
2026-07-11 18:42:35.322 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||
2026-07-11 18:42:35.536 +09:00 [ERR] Hosting failed to start
|
||||
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
|
||||
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
|
||||
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
|
||||
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
|
||||
--- End of stack trace from previous location ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
|
||||
2026-07-11 18:42:35.543 +09:00 [FTL] Application terminated unexpectedly
|
||||
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
|
||||
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
|
||||
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
|
||||
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
|
||||
--- End of stack trace from previous location ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.StartAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
|
||||
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
|
||||
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.Run(IHost host)
|
||||
at Program.<Main>$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 172
|
||||
2026-07-11 18:47:26.979 +09:00 [INF] Registered 10 endpoints in 173 milliseconds.
|
||||
2026-07-11 18:47:27.018 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||
2026-07-11 18:47:31.144 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:47:31.144 +09:00 [ERR] ❌ Database migration failed
|
||||
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||
2026-07-11 18:47:31.149 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:47:31.177 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||
2026-07-11 18:47:58.996 +09:00 [INF] Registered 10 endpoints in 163 milliseconds.
|
||||
2026-07-11 18:47:59.034 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||
2026-07-11 18:48:03.135 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:48:03.136 +09:00 [ERR] ❌ Database migration failed
|
||||
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||
2026-07-11 18:48:03.142 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:48:03.166 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||
2026-07-11 18:48:07.181 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||
2026-07-11 18:48:07.453 +09:00 [ERR] Hosting failed to start
|
||||
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
|
||||
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
|
||||
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
|
||||
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
|
||||
--- End of stack trace from previous location ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
|
||||
2026-07-11 18:48:07.460 +09:00 [FTL] Application terminated unexpectedly
|
||||
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
|
||||
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
|
||||
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
|
||||
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
|
||||
--- End of stack trace from previous location ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.StartAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
|
||||
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
|
||||
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.Run(IHost host)
|
||||
at Program.<Main>$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 172
|
||||
2026-07-11 18:49:42.328 +09:00 [INF] Registered 10 endpoints in 160 milliseconds.
|
||||
2026-07-11 18:49:42.368 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||
2026-07-11 18:49:46.632 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:49:46.632 +09:00 [ERR] ❌ Database migration failed
|
||||
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||
2026-07-11 18:49:46.635 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:49:46.655 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||
2026-07-11 18:49:50.782 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||
2026-07-11 18:49:50.940 +09:00 [INF] Now listening on: http://localhost:5265
|
||||
2026-07-11 18:49:50.946 +09:00 [INF] Starting Hangfire Server using job storage: 'Hangfire.MemoryStorage.MemoryStorage'
|
||||
2026-07-11 18:49:50.946 +09:00 [INF] Using the following options for Hangfire Server:
|
||||
Worker count: 32
|
||||
Listening queues: 'default'
|
||||
Shutdown timeout: 00:00:15
|
||||
Schedule polling interval: 00:00:15
|
||||
2026-07-11 18:49:50.966 +09:00 [INF] Application started. Press Ctrl+C to shut down.
|
||||
2026-07-11 18:49:50.966 +09:00 [INF] Hosting environment: Development
|
||||
2026-07-11 18:49:50.966 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
|
||||
2026-07-11 18:49:51.046 +09:00 [INF] Server kimjaehyun-note:18540:24fc9869 successfully announced in 77.2082 ms
|
||||
2026-07-11 18:49:51.050 +09:00 [INF] Server kimjaehyun-note:18540:24fc9869 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
|
||||
2026-07-11 18:49:51.076 +09:00 [INF] Server kimjaehyun-note:18540:24fc9869 all the dispatchers started
|
||||
2026-07-11 18:50:18.938 +09:00 [INF] Registered 10 endpoints in 161 milliseconds.
|
||||
2026-07-11 18:50:18.977 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||
2026-07-11 18:50:23.249 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:50:23.250 +09:00 [ERR] ❌ Database migration failed
|
||||
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||
2026-07-11 18:50:23.256 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:50:23.283 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||
2026-07-11 18:50:27.388 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||
2026-07-11 18:50:27.569 +09:00 [INF] Now listening on: http://localhost:5265
|
||||
2026-07-11 18:50:27.577 +09:00 [INF] Starting Hangfire Server using job storage: 'Hangfire.MemoryStorage.MemoryStorage'
|
||||
2026-07-11 18:50:27.577 +09:00 [INF] Using the following options for Hangfire Server:
|
||||
Worker count: 32
|
||||
Listening queues: 'default'
|
||||
Shutdown timeout: 00:00:15
|
||||
Schedule polling interval: 00:00:15
|
||||
2026-07-11 18:50:27.589 +09:00 [INF] Application started. Press Ctrl+C to shut down.
|
||||
2026-07-11 18:50:27.590 +09:00 [INF] Hosting environment: Development
|
||||
2026-07-11 18:50:27.590 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
|
||||
2026-07-11 18:50:27.646 +09:00 [INF] Server kimjaehyun-note:25888:2b65733c successfully announced in 54.5404 ms
|
||||
2026-07-11 18:50:27.648 +09:00 [INF] Server kimjaehyun-note:25888:2b65733c is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
|
||||
2026-07-11 18:50:27.667 +09:00 [INF] Server kimjaehyun-note:25888:2b65733c all the dispatchers started
|
||||
2026-07-11 18:50:40.967 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-11 18:50:40.972 +09:00 [WRN] Failed to determine the https port for redirect.
|
||||
2026-07-11 18:50:41.007 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-11 18:50:41.023 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-11 18:50:41.030 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-11 18:50:41.031 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-11 18:50:41.033 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-11 18:50:41.033 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-11 18:50:41.058 +09:00 [INF] Executed page /Account/Login in 32.7802ms
|
||||
2026-07-11 18:50:41.059 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-11 18:50:41.060 +09:00 [INF] HTTP GET /Account/Login responded 200 in 88.7566 ms
|
||||
2026-07-11 18:50:41.062 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 94.7893ms
|
||||
2026-07-11 18:50:41.174 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||
2026-07-11 18:50:41.178 +09:00 [ERR] HTTP GET /Admin/Dashboard responded 500 in 4.0554 ms
|
||||
System.InvalidOperationException: The AuthorizationPolicy named: 'AdminCookie' was not found.
|
||||
at Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable`1 authorizeData, IEnumerable`1 policies)
|
||||
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||
2026-07-11 18:50:41.181 +09:00 [ERR] An unhandled exception has occurred while executing the request.
|
||||
System.InvalidOperationException: The AuthorizationPolicy named: 'AdminCookie' was not found.
|
||||
at Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable`1 authorizeData, IEnumerable`1 policies)
|
||||
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
|
||||
2026-07-11 18:50:41.185 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 500 null text/plain; charset=utf-8 12.0749ms
|
||||
2026-07-11 18:50:41.298 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-11 18:50:41.303 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-11 18:50:41.303 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-11 18:50:41.304 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-11 18:50:41.304 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-11 18:50:41.304 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-11 18:50:41.304 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-11 18:50:41.310 +09:00 [INF] Executed page /Account/Login in 6.8704ms
|
||||
2026-07-11 18:50:41.310 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-11 18:50:41.310 +09:00 [INF] HTTP GET /Account/Login responded 200 in 12.5730 ms
|
||||
2026-07-11 18:50:41.311 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 12.9475ms
|
||||
2026-07-11 18:50:45.183 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||
2026-07-11 18:50:45.185 +09:00 [ERR] HTTP GET /Admin/Dashboard responded 500 in 1.8220 ms
|
||||
System.InvalidOperationException: The AuthorizationPolicy named: 'AdminCookie' was not found.
|
||||
at Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable`1 authorizeData, IEnumerable`1 policies)
|
||||
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||
2026-07-11 18:50:45.186 +09:00 [ERR] An unhandled exception has occurred while executing the request.
|
||||
System.InvalidOperationException: The AuthorizationPolicy named: 'AdminCookie' was not found.
|
||||
at Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable`1 authorizeData, IEnumerable`1 policies)
|
||||
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
|
||||
2026-07-11 18:50:45.186 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 500 null text/plain; charset=utf-8 2.8989ms
|
||||
2026-07-11 18:51:53.325 +09:00 [INF] Registered 10 endpoints in 159 milliseconds.
|
||||
2026-07-11 18:51:53.369 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||
2026-07-11 18:51:57.672 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:51:57.672 +09:00 [ERR] ❌ Database migration failed
|
||||
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||
2026-07-11 18:51:57.699 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-11 18:51:57.728 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||
2026-07-11 18:52:01.841 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||
2026-07-11 18:52:02.012 +09:00 [INF] Now listening on: http://localhost:5265
|
||||
2026-07-11 18:52:02.023 +09:00 [INF] Starting Hangfire Server using job storage: 'Hangfire.MemoryStorage.MemoryStorage'
|
||||
2026-07-11 18:52:02.024 +09:00 [INF] Using the following options for Hangfire Server:
|
||||
Worker count: 32
|
||||
Listening queues: 'default'
|
||||
Shutdown timeout: 00:00:15
|
||||
Schedule polling interval: 00:00:15
|
||||
2026-07-11 18:52:02.041 +09:00 [INF] Application started. Press Ctrl+C to shut down.
|
||||
2026-07-11 18:52:02.041 +09:00 [INF] Hosting environment: Development
|
||||
2026-07-11 18:52:02.041 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
|
||||
2026-07-11 18:52:02.106 +09:00 [INF] Server kimjaehyun-note:28380:a220bb7a successfully announced in 61.5885 ms
|
||||
2026-07-11 18:52:02.108 +09:00 [INF] Server kimjaehyun-note:28380:a220bb7a is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
|
||||
2026-07-11 18:52:02.115 +09:00 [INF] Server kimjaehyun-note:28380:a220bb7a all the dispatchers started
|
||||
2026-07-11 18:52:13.775 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-11 18:52:13.780 +09:00 [WRN] Failed to determine the https port for redirect.
|
||||
2026-07-11 18:52:13.833 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-11 18:52:13.849 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-11 18:52:13.855 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-11 18:52:13.856 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-11 18:52:13.858 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-11 18:52:13.859 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-11 18:52:13.883 +09:00 [INF] Executed page /Account/Login in 32.0627ms
|
||||
2026-07-11 18:52:13.884 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-11 18:52:13.885 +09:00 [INF] HTTP GET /Account/Login responded 200 in 106.3802 ms
|
||||
2026-07-11 18:52:13.887 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 112.9483ms
|
||||
2026-07-11 18:52:14.003 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||
2026-07-11 18:52:14.011 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||
2026-07-11 18:52:14.013 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||
2026-07-11 18:52:14.013 +09:00 [INF] HTTP GET /Admin/Dashboard responded 302 in 10.4176 ms
|
||||
2026-07-11 18:52:14.014 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 302 0 null 11.3635ms
|
||||
2026-07-11 18:52:14.120 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||
2026-07-11 18:52:14.124 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||
2026-07-11 18:52:14.124 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||
2026-07-11 18:52:14.125 +09:00 [INF] HTTP GET /Admin/Dashboard responded 302 in 4.4516 ms
|
||||
2026-07-11 18:52:14.125 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 302 0 null 4.7369ms
|
||||
2026-07-11 18:52:14.308 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||
2026-07-11 18:52:14.309 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||
2026-07-11 18:52:14.310 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||
2026-07-11 18:52:14.310 +09:00 [INF] HTTP GET /Admin/Dashboard responded 302 in 1.4155 ms
|
||||
2026-07-11 18:52:14.310 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 302 0 null 1.7032ms
|
||||
2026-07-11 18:52:14.410 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Users - null null
|
||||
2026-07-11 18:52:14.411 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||
2026-07-11 18:52:14.411 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||
2026-07-11 18:52:14.411 +09:00 [INF] HTTP GET /Admin/Users responded 302 in 0.4739 ms
|
||||
2026-07-11 18:52:14.411 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Users - 302 0 null 0.7314ms
|
||||
2026-07-11 18:52:14.523 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Collection - null null
|
||||
2026-07-11 18:52:14.523 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||
2026-07-11 18:52:14.523 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||
2026-07-11 18:52:14.524 +09:00 [INF] HTTP GET /Admin/Collection responded 302 in 0.6342 ms
|
||||
2026-07-11 18:52:14.524 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Collection - 302 0 null 1.6071ms
|
||||
2026-07-11 18:52:14.642 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Monitoring - null null
|
||||
2026-07-11 18:52:14.643 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||
2026-07-11 18:52:14.643 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||
2026-07-11 18:52:14.643 +09:00 [INF] HTTP GET /Admin/Monitoring responded 302 in 0.4305 ms
|
||||
2026-07-11 18:52:14.643 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Monitoring - 302 0 null 0.677ms
|
||||
2026-07-11 18:52:14.741 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Operations - null null
|
||||
2026-07-11 18:52:14.741 +09:00 [INF] Authorization failed. These requirements were not met:
|
||||
DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
|
||||
2026-07-11 18:52:14.742 +09:00 [INF] AuthenticationScheme: AdminCookie was challenged.
|
||||
2026-07-11 18:52:14.742 +09:00 [INF] HTTP GET /Admin/Operations responded 302 in 0.4306 ms
|
||||
2026-07-11 18:52:14.742 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Operations - 302 0 null 0.6763ms
|
||||
2026-07-11 21:04:06.174 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||
2026-07-11 21:04:06.177 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||
2026-07-11 21:04:06.177 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||
2026-07-11 21:04:06.178 +09:00 [INF] HTTP GET /login responded 302 in 2.1715 ms
|
||||
2026-07-11 21:04:06.178 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 3.7332ms
|
||||
2026-07-11 21:04:06.191 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-11 21:04:06.194 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-11 21:04:06.195 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-11 21:04:06.198 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-11 21:04:06.199 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-11 21:04:06.199 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-11 21:04:06.200 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-11 21:04:06.212 +09:00 [INF] Executed page /Account/Login in 16.6942ms
|
||||
2026-07-11 21:04:06.212 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-11 21:04:06.212 +09:00 [INF] HTTP GET /Account/Login responded 200 in 21.4926 ms
|
||||
2026-07-11 21:04:06.213 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 22.1426ms
|
||||
2026-07-11 21:04:06.222 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||
2026-07-11 21:04:06.223 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||
2026-07-11 21:04:06.223 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||
2026-07-11 21:04:06.223 +09:00 [INF] HTTP GET /login responded 302 in 0.5991 ms
|
||||
2026-07-11 21:04:06.223 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 1.2095ms
|
||||
2026-07-11 21:04:06.226 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-11 21:04:06.227 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-11 21:04:06.227 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-11 21:04:06.231 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-11 21:04:06.232 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-11 21:04:06.232 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-11 21:04:06.233 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-11 21:04:06.238 +09:00 [INF] Executed page /Account/Login in 10.6054ms
|
||||
2026-07-11 21:04:06.238 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-11 21:04:06.238 +09:00 [INF] HTTP GET /Account/Login responded 200 in 11.7060 ms
|
||||
2026-07-11 21:04:06.238 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 12.2449ms
|
||||
2026-07-11 21:05:05.597 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||
2026-07-11 21:05:05.597 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||
2026-07-11 21:05:05.598 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||
2026-07-11 21:05:05.598 +09:00 [INF] HTTP GET /login responded 302 in 0.4316 ms
|
||||
2026-07-11 21:05:05.598 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 0.8539ms
|
||||
2026-07-11 21:05:05.606 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-11 21:05:05.606 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-11 21:05:05.606 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-11 21:05:05.606 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-11 21:05:05.606 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-11 21:05:05.607 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-11 21:05:05.607 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-11 21:05:05.607 +09:00 [INF] Executed page /Account/Login in 1.1238ms
|
||||
2026-07-11 21:05:05.607 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-11 21:05:05.607 +09:00 [INF] HTTP GET /Account/Login responded 200 in 1.6523 ms
|
||||
2026-07-11 21:05:05.607 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 1.9459ms
|
||||
2026-07-11 21:05:05.611 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||
2026-07-11 21:05:05.612 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||
2026-07-11 21:05:05.612 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||
2026-07-11 21:05:05.612 +09:00 [INF] HTTP GET /login responded 302 in 0.3471 ms
|
||||
2026-07-11 21:05:05.612 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 0.603ms
|
||||
2026-07-11 21:05:05.616 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-11 21:05:05.616 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-11 21:05:05.616 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-11 21:05:05.616 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-11 21:05:05.616 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-11 21:05:05.616 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-11 21:05:05.616 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-11 21:05:05.617 +09:00 [INF] Executed page /Account/Login in 0.5619ms
|
||||
2026-07-11 21:05:05.617 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-11 21:05:05.617 +09:00 [INF] HTTP GET /Account/Login responded 200 in 0.9286 ms
|
||||
2026-07-11 21:05:05.617 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 1.1671ms
|
||||
@@ -0,0 +1,610 @@
|
||||
2026-07-12 01:49:59.393 +09:00 [INF] Registered 10 endpoints in 432 milliseconds.
|
||||
2026-07-12 01:49:59.756 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||
2026-07-12 01:50:02.068 +09:00 [INF] ✅ Database migration completed successfully
|
||||
2026-07-12 01:50:05.349 +09:00 [INF] Database migration and initialization successful
|
||||
2026-07-12 01:50:05.388 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||
2026-07-12 01:50:06.028 +09:00 [INF] Start installing Hangfire SQL objects...
|
||||
2026-07-12 01:50:10.702 +09:00 [INF] Hangfire SQL objects installed.
|
||||
2026-07-12 01:50:10.728 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||
2026-07-12 01:50:10.877 +09:00 [ERR] Hosting failed to start
|
||||
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
|
||||
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
|
||||
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
|
||||
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
|
||||
--- End of stack trace from previous location ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
|
||||
2026-07-12 01:50:10.905 +09:00 [FTL] Application terminated unexpectedly
|
||||
System.IO.IOException: Failed to bind to address http://127.0.0.1:5265: address already in use.
|
||||
---> Microsoft.AspNetCore.Connections.AddressInUseException: 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||
---> System.Net.Sockets.SocketException (10048): 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다.
|
||||
at System.Net.Sockets.Socket.UpdateStatusAfterSocketErrorAndThrowException(SocketError error, Boolean disconnectOnFailure, String callerName)
|
||||
at System.Net.Sockets.Socket.DoBind(EndPoint endPointSnapshot, SocketAddress socketAddress)
|
||||
at System.Net.Sockets.Socket.Bind(EndPoint localEP)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportOptions.CreateDefaultBoundListenSocket(EndPoint endpoint)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketConnectionListener.Bind()
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.SocketTransportFactory.BindAsync(EndPoint endpoint, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.TransportManager.BindAsync(EndPoint endPoint, ConnectionDelegate connectionDelegate, EndpointConfig endpointConfig, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.<>c__DisplayClass28_0`1.<<StartAsync>g__OnBind|0>d.MoveNext()
|
||||
--- End of stack trace from previous location ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||
--- End of inner exception stack trace ---
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.BindEndpointAsync(ListenOptions endpoint, AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.LocalhostListenOptions.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.AddressBinder.AddressesStrategy.BindAsync(AddressBindContext context, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.BindAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl.StartAsync[TContext](IHttpApplication`1 application, CancellationToken cancellationToken)
|
||||
at Microsoft.AspNetCore.Hosting.GenericWebHostService.StartAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__14_1(IHostedService service, CancellationToken token)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, Boolean concurrent, Boolean abortOnFirstException, List`1 exceptions, Func`3 operation)
|
||||
at Microsoft.Extensions.Hosting.Internal.Host.StartAsync(CancellationToken cancellationToken)
|
||||
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
|
||||
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
|
||||
at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.Run(IHost host)
|
||||
at Program.<Main>$(String[] args) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Program.cs:line 178
|
||||
2026-07-12 01:50:55.147 +09:00 [INF] Registered 10 endpoints in 161 milliseconds.
|
||||
2026-07-12 01:50:55.188 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||
2026-07-12 01:50:57.488 +09:00 [INF] ✅ Database migration completed successfully
|
||||
2026-07-12 01:51:00.589 +09:00 [INF] Database migration and initialization successful
|
||||
2026-07-12 01:51:00.610 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||
2026-07-12 01:51:00.632 +09:00 [INF] Start installing Hangfire SQL objects...
|
||||
2026-07-12 01:51:05.254 +09:00 [INF] Hangfire SQL objects installed.
|
||||
2026-07-12 01:51:05.261 +09:00 [WRN] Hangfire setup failed: Cannot resolve scoped service 'QuantEngine.Web.Services.SchedulerService' from root provider.
|
||||
2026-07-12 01:51:05.409 +09:00 [INF] Now listening on: http://localhost:5265
|
||||
2026-07-12 01:51:05.417 +09:00 [INF] Starting Hangfire Server using job storage: 'PostgreSQL Server: Host: 127.0.0.1, DB: quantenginedb, Schema: hangfire'
|
||||
2026-07-12 01:51:05.417 +09:00 [INF] Using the following options for PostgreSQL job storage:
|
||||
2026-07-12 01:51:05.418 +09:00 [INF] Queue poll interval: 00:00:15.
|
||||
2026-07-12 01:51:05.418 +09:00 [INF] Invisibility timeout: 00:30:00.
|
||||
2026-07-12 01:51:05.418 +09:00 [INF] Use sliding invisibility timeout: False.
|
||||
2026-07-12 01:51:05.418 +09:00 [INF] Using the following options for Hangfire Server:
|
||||
Worker count: 32
|
||||
Listening queues: 'default'
|
||||
Shutdown timeout: 00:00:15
|
||||
Schedule polling interval: 00:00:15
|
||||
2026-07-12 01:51:05.434 +09:00 [INF] Application started. Press Ctrl+C to shut down.
|
||||
2026-07-12 01:51:05.434 +09:00 [INF] Hosting environment: Development
|
||||
2026-07-12 01:51:05.434 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
|
||||
2026-07-12 01:51:05.741 +09:00 [INF] Server kimjaehyun-note:5816:6e6d8857 successfully announced in 304.3044 ms
|
||||
2026-07-12 01:51:05.744 +09:00 [INF] Server kimjaehyun-note:5816:6e6d8857 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
|
||||
2026-07-12 01:51:05.762 +09:00 [INF] Server kimjaehyun-note:5816:6e6d8857 all the dispatchers started
|
||||
2026-07-12 01:51:16.896 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-12 01:51:16.900 +09:00 [WRN] Failed to determine the https port for redirect.
|
||||
2026-07-12 01:51:16.932 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 01:51:16.958 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 01:51:16.970 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-12 01:51:16.972 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-12 01:51:16.977 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 01:51:16.978 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 01:51:17.024 +09:00 [INF] Executed page /Account/Login in 63.0425ms
|
||||
2026-07-12 01:51:17.025 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 01:51:17.026 +09:00 [INF] HTTP GET /Account/Login responded 200 in 126.7878 ms
|
||||
2026-07-12 01:51:17.028 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 132.7688ms
|
||||
2026-07-12 01:51:42.662 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-12 01:51:42.685 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 01:51:42.685 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 01:51:42.687 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-12 01:51:42.687 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-12 01:51:42.687 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 01:51:42.687 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 01:51:42.694 +09:00 [INF] Executed page /Account/Login in 9.2179ms
|
||||
2026-07-12 01:51:42.695 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 01:51:42.695 +09:00 [INF] HTTP GET /Account/Login responded 200 in 32.3239 ms
|
||||
2026-07-12 01:51:42.695 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 34.0934ms
|
||||
2026-07-12 01:51:43.344 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 218
|
||||
2026-07-12 01:51:43.352 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 01:51:43.353 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 01:51:43.402 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
|
||||
2026-07-12 01:51:44.200 +09:00 [INF] AuthenticationScheme: AdminCookie signed in.
|
||||
2026-07-12 01:51:44.201 +09:00 [INF] [Login] User 'admin' authenticated successfully from ::1
|
||||
2026-07-12 01:51:44.205 +09:00 [INF] Executed handler method OnPostAsync, returned result Microsoft.AspNetCore.Mvc.LocalRedirectResult.
|
||||
2026-07-12 01:51:44.210 +09:00 [INF] Executing LocalRedirectResult, redirecting to /Admin/Dashboard.
|
||||
2026-07-12 01:51:44.213 +09:00 [INF] Executed page /Account/Login in 859.92ms
|
||||
2026-07-12 01:51:44.214 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 01:51:44.216 +09:00 [INF] HTTP POST /Account/Login responded 302 in 871.2169 ms
|
||||
2026-07-12 01:51:44.218 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 302 0 null 873.6838ms
|
||||
2026-07-12 01:51:44.222 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||
2026-07-12 01:51:44.232 +09:00 [INF] Executing endpoint '/Admin/Dashboard/Index'
|
||||
2026-07-12 01:51:44.252 +09:00 [INF] Route matched with {page = "/Admin/Dashboard/Index"}. Executing page /Admin/Dashboard/Index
|
||||
2026-07-12 01:51:44.253 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Dashboard.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||
2026-07-12 01:51:45.151 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||
2026-07-12 01:51:45.151 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 01:51:45.152 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 01:51:45.229 +09:00 [INF] Executed page /Admin/Dashboard/Index in 976.4261ms
|
||||
2026-07-12 01:51:45.229 +09:00 [INF] Executed endpoint '/Admin/Dashboard/Index'
|
||||
2026-07-12 01:51:45.230 +09:00 [INF] HTTP GET /Admin/Dashboard responded 200 in 1007.5596 ms
|
||||
2026-07-12 01:51:45.230 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 200 null text/html; charset=utf-8 1008.1707ms
|
||||
2026-07-12 01:51:45.238 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/css/admin.css - null null
|
||||
2026-07-12 01:51:45.255 +09:00 [INF] Sending file. Request path: '/css/admin.css'. Physical path: 'C:\Temp\data_feed\src\dotnet\QuantEngine.Web\wwwroot\css\admin.css'
|
||||
2026-07-12 01:51:45.256 +09:00 [INF] HTTP GET /css/admin.css responded 200 in 17.9079 ms
|
||||
2026-07-12 01:51:45.256 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/css/admin.css - 200 3956 text/css 18.4204ms
|
||||
2026-07-12 01:51:45.933 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Operations - null null
|
||||
2026-07-12 01:51:45.937 +09:00 [INF] Executing endpoint '/Admin/Operations/Index'
|
||||
2026-07-12 01:51:45.951 +09:00 [INF] Route matched with {page = "/Admin/Operations/Index"}. Executing page /Admin/Operations/Index
|
||||
2026-07-12 01:51:45.952 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Operations.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||
2026-07-12 01:51:48.823 +09:00 [INF] Operations data loaded from Hangfire (4 recurring jobs, 2 servers)
|
||||
2026-07-12 01:51:48.824 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||
2026-07-12 01:51:48.824 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 01:51:48.824 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 01:51:48.846 +09:00 [INF] Executed page /Admin/Operations/Index in 2894.9938ms
|
||||
2026-07-12 01:51:48.847 +09:00 [INF] Executed endpoint '/Admin/Operations/Index'
|
||||
2026-07-12 01:51:48.847 +09:00 [INF] HTTP GET /Admin/Operations responded 200 in 2913.4775 ms
|
||||
2026-07-12 01:51:48.847 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Operations - 200 null text/html; charset=utf-8 2914.1336ms
|
||||
2026-07-12 01:51:49.732 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-12 01:51:49.732 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 01:51:49.732 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 01:51:49.732 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-12 01:51:49.733 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-12 01:51:49.733 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 01:51:49.733 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 01:51:49.735 +09:00 [INF] Executed page /Account/Login in 2.6158ms
|
||||
2026-07-12 01:51:49.735 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 01:51:49.735 +09:00 [INF] HTTP GET /Account/Login responded 200 in 3.4623 ms
|
||||
2026-07-12 01:51:49.735 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 3.8328ms
|
||||
2026-07-12 01:51:50.371 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 218
|
||||
2026-07-12 01:51:50.371 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 01:51:50.371 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 01:51:50.373 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
|
||||
2026-07-12 01:51:50.857 +09:00 [INF] AuthenticationScheme: AdminCookie signed in.
|
||||
2026-07-12 01:51:50.857 +09:00 [INF] [Login] User 'admin' authenticated successfully from ::1
|
||||
2026-07-12 01:51:50.857 +09:00 [INF] Executed handler method OnPostAsync, returned result Microsoft.AspNetCore.Mvc.LocalRedirectResult.
|
||||
2026-07-12 01:51:50.858 +09:00 [INF] Executing LocalRedirectResult, redirecting to /Admin/Dashboard.
|
||||
2026-07-12 01:51:50.858 +09:00 [INF] Executed page /Account/Login in 486.6264ms
|
||||
2026-07-12 01:51:50.858 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 01:51:50.858 +09:00 [INF] HTTP POST /Account/Login responded 302 in 487.2066 ms
|
||||
2026-07-12 01:51:50.858 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 302 0 null 487.636ms
|
||||
2026-07-12 01:51:50.862 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||
2026-07-12 01:51:50.865 +09:00 [INF] Executing endpoint '/Admin/Dashboard/Index'
|
||||
2026-07-12 01:51:50.865 +09:00 [INF] Route matched with {page = "/Admin/Dashboard/Index"}. Executing page /Admin/Dashboard/Index
|
||||
2026-07-12 01:51:50.865 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Dashboard.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||
2026-07-12 01:51:51.755 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||
2026-07-12 01:51:51.755 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 01:51:51.755 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 01:51:51.757 +09:00 [INF] Executed page /Admin/Dashboard/Index in 891.4622ms
|
||||
2026-07-12 01:51:51.757 +09:00 [INF] Executed endpoint '/Admin/Dashboard/Index'
|
||||
2026-07-12 01:51:51.757 +09:00 [INF] HTTP GET /Admin/Dashboard responded 200 in 894.9356 ms
|
||||
2026-07-12 01:51:51.757 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 200 null text/html; charset=utf-8 895.2421ms
|
||||
2026-07-12 01:51:51.766 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/css/admin.css - null null
|
||||
2026-07-12 01:51:51.779 +09:00 [INF] Sending file. Request path: '/css/admin.css'. Physical path: 'C:\Temp\data_feed\src\dotnet\QuantEngine.Web\wwwroot\css\admin.css'
|
||||
2026-07-12 01:51:51.779 +09:00 [INF] HTTP GET /css/admin.css responded 200 in 12.9029 ms
|
||||
2026-07-12 01:51:51.780 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/css/admin.css - 200 3956 text/css 13.3629ms
|
||||
2026-07-12 01:52:45.383 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-12 01:52:45.383 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 01:52:45.383 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 01:52:45.383 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-12 01:52:45.383 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-12 01:52:45.383 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 01:52:45.383 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 01:52:45.383 +09:00 [INF] Executed page /Account/Login in 0.5097ms
|
||||
2026-07-12 01:52:45.384 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 01:52:45.384 +09:00 [INF] HTTP GET /Account/Login responded 200 in 0.8798 ms
|
||||
2026-07-12 01:52:45.384 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 1.0536ms
|
||||
2026-07-12 01:52:46.018 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 218
|
||||
2026-07-12 01:52:46.018 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 01:52:46.018 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 01:52:46.019 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
|
||||
2026-07-12 01:52:46.475 +09:00 [INF] AuthenticationScheme: AdminCookie signed in.
|
||||
2026-07-12 01:52:46.475 +09:00 [INF] [Login] User 'admin' authenticated successfully from ::1
|
||||
2026-07-12 01:52:46.475 +09:00 [INF] Executed handler method OnPostAsync, returned result Microsoft.AspNetCore.Mvc.LocalRedirectResult.
|
||||
2026-07-12 01:52:46.475 +09:00 [INF] Executing LocalRedirectResult, redirecting to /Admin/Dashboard.
|
||||
2026-07-12 01:52:46.475 +09:00 [INF] Executed page /Account/Login in 456.9281ms
|
||||
2026-07-12 01:52:46.475 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 01:52:46.475 +09:00 [INF] HTTP POST /Account/Login responded 302 in 457.3632 ms
|
||||
2026-07-12 01:52:46.475 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 302 0 null 457.5915ms
|
||||
2026-07-12 01:52:46.478 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - null null
|
||||
2026-07-12 01:52:46.479 +09:00 [INF] Executing endpoint '/Admin/Dashboard/Index'
|
||||
2026-07-12 01:52:46.479 +09:00 [INF] Route matched with {page = "/Admin/Dashboard/Index"}. Executing page /Admin/Dashboard/Index
|
||||
2026-07-12 01:52:46.479 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Dashboard.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||
2026-07-12 01:52:47.363 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||
2026-07-12 01:52:47.363 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 01:52:47.363 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 01:52:47.366 +09:00 [INF] Executed page /Admin/Dashboard/Index in 886.9837ms
|
||||
2026-07-12 01:52:47.366 +09:00 [INF] Executed endpoint '/Admin/Dashboard/Index'
|
||||
2026-07-12 01:52:47.366 +09:00 [INF] HTTP GET /Admin/Dashboard responded 200 in 888.0406 ms
|
||||
2026-07-12 01:52:47.367 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Dashboard - 200 null text/html; charset=utf-8 888.4149ms
|
||||
2026-07-12 01:52:47.378 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/css/admin.css - null null
|
||||
2026-07-12 01:52:47.380 +09:00 [INF] Sending file. Request path: '/css/admin.css'. Physical path: 'C:\Temp\data_feed\src\dotnet\QuantEngine.Web\wwwroot\css\admin.css'
|
||||
2026-07-12 01:52:47.380 +09:00 [INF] HTTP GET /css/admin.css responded 200 in 2.4015 ms
|
||||
2026-07-12 01:52:47.380 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/css/admin.css - 200 3956 text/css 2.7681ms
|
||||
2026-07-12 01:52:48.337 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Collection - null null
|
||||
2026-07-12 01:52:48.338 +09:00 [INF] Executing endpoint '/Admin/Collection/Index'
|
||||
2026-07-12 01:52:48.355 +09:00 [INF] Route matched with {page = "/Admin/Collection/Index"}. Executing page /Admin/Collection/Index
|
||||
2026-07-12 01:52:48.355 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Collection.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||
2026-07-12 01:52:48.581 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||
2026-07-12 01:52:48.581 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 01:52:48.581 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 01:52:48.591 +09:00 [INF] Executed page /Admin/Collection/Index in 236.0372ms
|
||||
2026-07-12 01:52:48.591 +09:00 [INF] Executed endpoint '/Admin/Collection/Index'
|
||||
2026-07-12 01:52:48.591 +09:00 [INF] HTTP GET /Admin/Collection responded 200 in 254.0162 ms
|
||||
2026-07-12 01:52:48.592 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Collection - 200 null text/html; charset=utf-8 254.527ms
|
||||
2026-07-12 01:52:49.211 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Monitoring - null null
|
||||
2026-07-12 01:52:49.211 +09:00 [INF] Executing endpoint '/Admin/Monitoring/Index'
|
||||
2026-07-12 01:52:49.217 +09:00 [INF] Route matched with {page = "/Admin/Monitoring/Index"}. Executing page /Admin/Monitoring/Index
|
||||
2026-07-12 01:52:49.218 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Monitoring.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||
2026-07-12 01:52:49.440 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||
2026-07-12 01:52:49.440 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 01:52:49.440 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 01:52:49.453 +09:00 [INF] Executed page /Admin/Monitoring/Index in 235.3992ms
|
||||
2026-07-12 01:52:49.453 +09:00 [INF] Executed endpoint '/Admin/Monitoring/Index'
|
||||
2026-07-12 01:52:49.453 +09:00 [INF] HTTP GET /Admin/Monitoring responded 200 in 242.4802 ms
|
||||
2026-07-12 01:52:49.453 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Monitoring - 200 null text/html; charset=utf-8 242.724ms
|
||||
2026-07-12 01:52:50.106 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Admin/Users - null null
|
||||
2026-07-12 01:52:50.107 +09:00 [INF] Executing endpoint '/Admin/Users/Index'
|
||||
2026-07-12 01:52:50.117 +09:00 [INF] Route matched with {page = "/Admin/Users/Index"}. Executing page /Admin/Users/Index
|
||||
2026-07-12 01:52:50.118 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Admin.Users.IndexModel.OnGetAsync - ModelState is "Valid"
|
||||
2026-07-12 01:52:50.339 +09:00 [INF] Executed handler method OnGetAsync, returned result .
|
||||
2026-07-12 01:52:50.339 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 01:52:50.339 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 01:52:50.346 +09:00 [INF] Executed page /Admin/Users/Index in 228.3491ms
|
||||
2026-07-12 01:52:50.346 +09:00 [INF] Executed endpoint '/Admin/Users/Index'
|
||||
2026-07-12 01:52:50.346 +09:00 [INF] HTTP GET /Admin/Users responded 200 in 239.3317 ms
|
||||
2026-07-12 01:52:50.346 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Admin/Users - 200 null text/html; charset=utf-8 239.5416ms
|
||||
2026-07-12 01:56:32.591 +09:00 [INF] Registered 10 endpoints in 168 milliseconds.
|
||||
2026-07-12 01:56:32.627 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||
2026-07-12 01:56:34.908 +09:00 [INF] ✅ Database migration completed successfully
|
||||
2026-07-12 01:56:37.976 +09:00 [INF] Database migration and initialization successful
|
||||
2026-07-12 01:56:38.000 +09:00 [INF] User profile is available. Using 'C:\Users\kjh20\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
|
||||
2026-07-12 01:56:38.043 +09:00 [INF] Start installing Hangfire SQL objects...
|
||||
2026-07-12 01:56:42.666 +09:00 [INF] Hangfire SQL objects installed.
|
||||
2026-07-12 01:56:42.681 +09:00 [INF] Initializing Hangfire schedules...
|
||||
2026-07-12 01:56:50.823 +09:00 [INF] Hangfire schedules initialized successfully
|
||||
2026-07-12 01:56:50.988 +09:00 [INF] Now listening on: http://localhost:5265
|
||||
2026-07-12 01:56:50.992 +09:00 [INF] Starting Hangfire Server using job storage: 'PostgreSQL Server: Host: 127.0.0.1, DB: quantenginedb, Schema: hangfire'
|
||||
2026-07-12 01:56:50.992 +09:00 [INF] Using the following options for PostgreSQL job storage:
|
||||
2026-07-12 01:56:50.992 +09:00 [INF] Queue poll interval: 00:00:15.
|
||||
2026-07-12 01:56:50.992 +09:00 [INF] Invisibility timeout: 00:30:00.
|
||||
2026-07-12 01:56:50.992 +09:00 [INF] Use sliding invisibility timeout: False.
|
||||
2026-07-12 01:56:50.992 +09:00 [INF] Using the following options for Hangfire Server:
|
||||
Worker count: 32
|
||||
Listening queues: 'default'
|
||||
Shutdown timeout: 00:00:15
|
||||
Schedule polling interval: 00:00:15
|
||||
2026-07-12 01:56:51.000 +09:00 [INF] Application started. Press Ctrl+C to shut down.
|
||||
2026-07-12 01:56:51.000 +09:00 [INF] Hosting environment: Development
|
||||
2026-07-12 01:56:51.000 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
|
||||
2026-07-12 01:56:51.234 +09:00 [INF] Server kimjaehyun-note:18688:50858618 successfully announced in 231.3723 ms
|
||||
2026-07-12 01:56:51.239 +09:00 [INF] Server kimjaehyun-note:18688:50858618 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
|
||||
2026-07-12 01:56:51.251 +09:00 [INF] Server kimjaehyun-note:18688:50858618 all the dispatchers started
|
||||
2026-07-12 01:56:52.918 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-12 01:56:52.923 +09:00 [WRN] Failed to determine the https port for redirect.
|
||||
2026-07-12 01:56:52.975 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 01:56:53.000 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 01:56:53.008 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-12 01:56:53.009 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-12 01:56:53.011 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 01:56:53.012 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 01:56:53.043 +09:00 [INF] Executed page /Account/Login in 38.7733ms
|
||||
2026-07-12 01:56:53.043 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 01:56:53.045 +09:00 [INF] HTTP GET /Account/Login responded 200 in 123.2950 ms
|
||||
2026-07-12 01:56:53.048 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 131.2244ms
|
||||
2026-07-12 10:29:03.142 +09:00 [INF] Registered 10 endpoints in 4,906 milliseconds.
|
||||
2026-07-12 10:29:03.556 +09:00 [INF] 🔄 Starting database migration with DbUp...
|
||||
2026-07-12 10:29:07.251 +09:00 [ERR] ❌ Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-12 10:29:07.251 +09:00 [ERR] ❌ Database migration failed
|
||||
System.InvalidOperationException: Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
at QuantEngine.Infrastructure.Data.DbMigrator.Migrate() in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Data\DbMigrator.cs:line 39
|
||||
2026-07-12 10:29:07.276 +09:00 [WRN] Database initialization warning (development only): Database migration failed: 28P01: password authentication failed for user "quantengine_app"
|
||||
2026-07-12 10:29:07.442 +09:00 [INF] Initializing Hangfire schedules...
|
||||
2026-07-12 10:29:07.565 +09:00 [INF] Hangfire schedules initialized successfully
|
||||
2026-07-12 10:29:07.677 +09:00 [INF] Creating key {49191b44-5683-456a-b1ee-0ddbc2c7fc29} with creation date 2026-07-12 01:29:07Z, activation date 2026-07-12 01:29:07Z, and expiration date 2026-10-10 01:29:07Z.
|
||||
2026-07-12 10:29:07.689 +09:00 [WRN] No XML encryptor configured. Key {49191b44-5683-456a-b1ee-0ddbc2c7fc29} may be persisted to storage in unencrypted form.
|
||||
2026-07-12 10:29:07.697 +09:00 [INF] Writing data to file 'C:\Users\kjh20\AppData\Local\quantengine-keys\key-49191b44-5683-456a-b1ee-0ddbc2c7fc29.xml'.
|
||||
2026-07-12 10:29:07.829 +09:00 [INF] Now listening on: http://localhost:5265
|
||||
2026-07-12 10:29:07.833 +09:00 [INF] Starting Hangfire Server using job storage: 'Hangfire.MemoryStorage.MemoryStorage'
|
||||
2026-07-12 10:29:07.834 +09:00 [INF] Using the following options for Hangfire Server:
|
||||
Worker count: 32
|
||||
Listening queues: 'default'
|
||||
Shutdown timeout: 00:00:15
|
||||
Schedule polling interval: 00:00:15
|
||||
2026-07-12 10:29:07.843 +09:00 [INF] Application started. Press Ctrl+C to shut down.
|
||||
2026-07-12 10:29:07.843 +09:00 [INF] Hosting environment: Development
|
||||
2026-07-12 10:29:07.843 +09:00 [INF] Content root path: C:\Temp\data_feed\src\dotnet\QuantEngine.Web
|
||||
2026-07-12 10:29:07.850 +09:00 [INF] Server kimjaehyun-note:27604:c5019131 successfully announced in 5.0644 ms
|
||||
2026-07-12 10:29:07.852 +09:00 [INF] Server kimjaehyun-note:27604:c5019131 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
|
||||
2026-07-12 10:29:07.863 +09:00 [INF] Server kimjaehyun-note:27604:c5019131 all the dispatchers started
|
||||
2026-07-12 10:29:10.923 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-12 10:29:10.929 +09:00 [WRN] Failed to determine the https port for redirect.
|
||||
2026-07-12 10:29:10.969 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 10:29:10.987 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 10:29:10.994 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-12 10:29:10.995 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-12 10:29:10.997 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 10:29:10.998 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 10:29:11.026 +09:00 [INF] Executed page /Account/Login in 35.8464ms
|
||||
2026-07-12 10:29:11.027 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 10:29:11.028 +09:00 [INF] HTTP GET /Account/Login responded 200 in 99.7375 ms
|
||||
2026-07-12 10:29:11.030 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 121.6214ms
|
||||
2026-07-12 10:54:40.138 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||
2026-07-12 10:54:40.144 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||
2026-07-12 10:54:40.145 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||
2026-07-12 10:54:40.145 +09:00 [INF] HTTP GET /login responded 302 in 6.0857 ms
|
||||
2026-07-12 10:54:40.146 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 9.206ms
|
||||
2026-07-12 10:54:40.153 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-12 10:54:40.163 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 10:54:40.163 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 10:54:40.165 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-12 10:54:40.165 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-12 10:54:40.165 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 10:54:40.166 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 10:54:40.179 +09:00 [INF] Executed page /Account/Login in 15.2716ms
|
||||
2026-07-12 10:54:40.179 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 10:54:40.179 +09:00 [INF] HTTP GET /Account/Login responded 200 in 25.4427 ms
|
||||
2026-07-12 10:54:40.180 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 26.1015ms
|
||||
2026-07-12 10:54:40.185 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||
2026-07-12 10:54:40.186 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||
2026-07-12 10:54:40.187 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||
2026-07-12 10:54:40.187 +09:00 [INF] HTTP GET /login responded 302 in 1.4987 ms
|
||||
2026-07-12 10:54:40.187 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 2.2724ms
|
||||
2026-07-12 10:54:40.190 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-12 10:54:40.191 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 10:54:40.191 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 10:54:40.195 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-12 10:54:40.196 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-12 10:54:40.196 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 10:54:40.196 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 10:54:40.203 +09:00 [INF] Executed page /Account/Login in 11.1274ms
|
||||
2026-07-12 10:54:40.203 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 10:54:40.203 +09:00 [INF] HTTP GET /Account/Login responded 200 in 12.4260 ms
|
||||
2026-07-12 10:54:40.203 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 13.0096ms
|
||||
2026-07-12 10:54:44.086 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-12 10:54:44.087 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 10:54:44.087 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 10:54:44.087 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-12 10:54:44.087 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-12 10:54:44.088 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 10:54:44.088 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 10:54:44.088 +09:00 [INF] Executed page /Account/Login in 1.1538ms
|
||||
2026-07-12 10:54:44.089 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 10:54:44.089 +09:00 [INF] HTTP GET /Account/Login responded 200 in 1.8741 ms
|
||||
2026-07-12 10:54:44.089 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 2.4044ms
|
||||
2026-07-12 10:54:44.647 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 212
|
||||
2026-07-12 10:54:44.649 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 10:54:44.649 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 10:54:44.686 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
|
||||
2026-07-12 10:54:48.980 +09:00 [INF] Executed page /Account/Login in 4330.4506ms
|
||||
2026-07-12 10:54:48.981 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 10:54:48.984 +09:00 [ERR] HTTP POST /Account/Login responded 500 in 4336.7156 ms
|
||||
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "quantengine_app"
|
||||
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
|
||||
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
|
||||
at Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List`1 mechanisms, String username, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.Authenticate(String username, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(Boolean async, CancellationToken cancellationToken)
|
||||
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 488
|
||||
at QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(String username) in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Repositories\WorkspaceRepository.cs:line 35
|
||||
at QuantEngine.Web.Services.AuthService.AuthenticateAsync(String username, String password, String ipAddress) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Services\AuthService.cs:line 26
|
||||
at QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(String username, String password, Boolean rememberUsername) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Pages\Account\Login.cshtml.cs:line 49
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Convert[T](Object taskAsObject)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Execute(Object receiver, Object[] arguments)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
|
||||
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||
Exception data:
|
||||
Severity: FATAL
|
||||
SqlState: 28P01
|
||||
MessageText: password authentication failed for user "quantengine_app"
|
||||
File: auth.c
|
||||
Line: 317
|
||||
Routine: auth_failed
|
||||
2026-07-12 10:54:49.034 +09:00 [ERR] An unhandled exception has occurred while executing the request.
|
||||
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "quantengine_app"
|
||||
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
|
||||
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
|
||||
at Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List`1 mechanisms, String username, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.Authenticate(String username, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(Boolean async, CancellationToken cancellationToken)
|
||||
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 488
|
||||
at QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(String username) in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Repositories\WorkspaceRepository.cs:line 35
|
||||
at QuantEngine.Web.Services.AuthService.AuthenticateAsync(String username, String password, String ipAddress) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Services\AuthService.cs:line 26
|
||||
at QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(String username, String password, Boolean rememberUsername) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Pages\Account\Login.cshtml.cs:line 49
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Convert[T](Object taskAsObject)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Execute(Object receiver, Object[] arguments)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
|
||||
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
|
||||
Exception data:
|
||||
Severity: FATAL
|
||||
SqlState: 28P01
|
||||
MessageText: password authentication failed for user "quantengine_app"
|
||||
File: auth.c
|
||||
Line: 317
|
||||
Routine: auth_failed
|
||||
2026-07-12 10:54:49.111 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 500 null text/html; charset=utf-8 4464.5985ms
|
||||
2026-07-12 10:54:49.205 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/api/collection/runs?limit=20 - null null
|
||||
2026-07-12 10:54:49.207 +09:00 [INF] Executing endpoint 'HTTP: GET /api/collection/runs'
|
||||
2026-07-12 10:54:52.982 +09:00 [INF] Executed endpoint 'HTTP: GET /api/collection/runs'
|
||||
2026-07-12 10:54:52.982 +09:00 [ERR] HTTP GET /api/collection/runs responded 500 in 3777.5166 ms
|
||||
2026-07-12 10:54:52.983 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/api/collection/runs?limit=20 - 500 null application/problem+json; charset=utf-8 3778.0479ms
|
||||
2026-07-12 13:02:07.505 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||
2026-07-12 13:02:07.523 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||
2026-07-12 13:02:07.523 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||
2026-07-12 13:02:07.524 +09:00 [INF] HTTP GET /login responded 302 in 16.0250 ms
|
||||
2026-07-12 13:02:07.529 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 21.1915ms
|
||||
2026-07-12 13:02:07.531 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-12 13:02:07.531 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 13:02:07.540 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 13:02:07.546 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-12 13:02:07.548 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-12 13:02:07.548 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 13:02:07.550 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 13:02:07.581 +09:00 [INF] Executed page /Account/Login in 40.445ms
|
||||
2026-07-12 13:02:07.581 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 13:02:07.581 +09:00 [INF] HTTP GET /Account/Login responded 200 in 49.9978 ms
|
||||
2026-07-12 13:02:07.581 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 50.4194ms
|
||||
2026-07-12 13:02:07.585 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/login - null null
|
||||
2026-07-12 13:02:07.585 +09:00 [INF] Executing endpoint 'HTTP: GET /login'
|
||||
2026-07-12 13:02:07.585 +09:00 [INF] Executed endpoint 'HTTP: GET /login'
|
||||
2026-07-12 13:02:07.585 +09:00 [INF] HTTP GET /login responded 302 in 0.3455 ms
|
||||
2026-07-12 13:02:07.585 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/login - 302 0 null 0.6015ms
|
||||
2026-07-12 13:02:07.586 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-12 13:02:07.587 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 13:02:07.587 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 13:02:07.587 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-12 13:02:07.587 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-12 13:02:07.588 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 13:02:07.588 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 13:02:07.589 +09:00 [INF] Executed page /Account/Login in 1.3984ms
|
||||
2026-07-12 13:02:07.589 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 13:02:07.589 +09:00 [INF] HTTP GET /Account/Login responded 200 in 2.2170 ms
|
||||
2026-07-12 13:02:07.589 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 2.4854ms
|
||||
2026-07-12 13:02:11.141 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/Account/Login - null null
|
||||
2026-07-12 13:02:11.142 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 13:02:11.142 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 13:02:11.142 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnGet - ModelState is "Valid"
|
||||
2026-07-12 13:02:11.143 +09:00 [INF] Executed handler method OnGet, returned result .
|
||||
2026-07-12 13:02:11.143 +09:00 [INF] Executing an implicit handler method - ModelState is "Valid"
|
||||
2026-07-12 13:02:11.143 +09:00 [INF] Executed an implicit handler method, returned result Microsoft.AspNetCore.Mvc.RazorPages.PageResult.
|
||||
2026-07-12 13:02:11.144 +09:00 [INF] Executed page /Account/Login in 1.7154ms
|
||||
2026-07-12 13:02:11.144 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 13:02:11.144 +09:00 [INF] HTTP GET /Account/Login responded 200 in 2.8689 ms
|
||||
2026-07-12 13:02:11.144 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/Account/Login - 200 null text/html; charset=utf-8 3.2765ms
|
||||
2026-07-12 13:02:11.640 +09:00 [INF] Request starting HTTP/1.1 POST http://localhost:5265/Account/Login - application/x-www-form-urlencoded 212
|
||||
2026-07-12 13:02:11.643 +09:00 [INF] Executing endpoint '/Account/Login'
|
||||
2026-07-12 13:02:11.644 +09:00 [INF] Route matched with {page = "/Account/Login"}. Executing page /Account/Login
|
||||
2026-07-12 13:02:11.684 +09:00 [INF] Executing handler method QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync - ModelState is "Valid"
|
||||
2026-07-12 13:02:15.491 +09:00 [INF] Executed page /Account/Login in 3846.9073ms
|
||||
2026-07-12 13:02:15.493 +09:00 [INF] Executed endpoint '/Account/Login'
|
||||
2026-07-12 13:02:15.495 +09:00 [ERR] HTTP POST /Account/Login responded 500 in 3854.1437 ms
|
||||
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "quantengine_app"
|
||||
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
|
||||
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
|
||||
at Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List`1 mechanisms, String username, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.Authenticate(String username, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(Boolean async, CancellationToken cancellationToken)
|
||||
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 488
|
||||
at QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(String username) in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Repositories\WorkspaceRepository.cs:line 35
|
||||
at QuantEngine.Web.Services.AuthService.AuthenticateAsync(String username, String password, String ipAddress) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Services\AuthService.cs:line 26
|
||||
at QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(String username, String password, Boolean rememberUsername) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Pages\Account\Login.cshtml.cs:line 49
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Convert[T](Object taskAsObject)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Execute(Object receiver, Object[] arguments)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
|
||||
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||
Exception data:
|
||||
Severity: FATAL
|
||||
SqlState: 28P01
|
||||
MessageText: password authentication failed for user "quantengine_app"
|
||||
File: auth.c
|
||||
Line: 317
|
||||
Routine: auth_failed
|
||||
2026-07-12 13:02:15.530 +09:00 [ERR] An unhandled exception has occurred while executing the request.
|
||||
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "quantengine_app"
|
||||
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
|
||||
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
|
||||
at Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List`1 mechanisms, String username, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.Authenticate(String username, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, String username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, Boolean async, CancellationToken cancellationToken)
|
||||
at Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(Boolean async, CancellationToken cancellationToken)
|
||||
at Dapper.SqlMapper.QueryRowAsync[T](IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in /_/Dapper/SqlMapper.Async.cs:line 488
|
||||
at QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(String username) in C:\Temp\data_feed\src\dotnet\QuantEngine.Infrastructure\Repositories\WorkspaceRepository.cs:line 35
|
||||
at QuantEngine.Web.Services.AuthService.AuthenticateAsync(String username, String password, String ipAddress) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Services\AuthService.cs:line 26
|
||||
at QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(String username, String password, Boolean rememberUsername) in C:\Temp\data_feed\src\dotnet\QuantEngine.Web\Pages\Account\Login.cshtml.cs:line 49
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Convert[T](Object taskAsObject)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory.GenericTaskHandlerMethod.Execute(Object receiver, Object[] arguments)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
|
||||
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)
|
||||
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
|
||||
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
|
||||
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
|
||||
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
|
||||
Exception data:
|
||||
Severity: FATAL
|
||||
SqlState: 28P01
|
||||
MessageText: password authentication failed for user "quantengine_app"
|
||||
File: auth.c
|
||||
Line: 317
|
||||
Routine: auth_failed
|
||||
2026-07-12 13:02:15.576 +09:00 [INF] Request finished HTTP/1.1 POST http://localhost:5265/Account/Login - 500 null text/html; charset=utf-8 3935.4857ms
|
||||
2026-07-12 13:02:15.664 +09:00 [INF] Request starting HTTP/1.1 GET http://localhost:5265/api/collection/runs?limit=20 - null null
|
||||
2026-07-12 13:02:15.668 +09:00 [INF] Executing endpoint 'HTTP: GET /api/collection/runs'
|
||||
2026-07-12 13:02:19.317 +09:00 [INF] Executed endpoint 'HTTP: GET /api/collection/runs'
|
||||
2026-07-12 13:02:19.318 +09:00 [ERR] HTTP GET /api/collection/runs responded 500 in 3653.6595 ms
|
||||
2026-07-12 13:02:19.318 +09:00 [INF] Request finished HTTP/1.1 GET http://localhost:5265/api/collection/runs?limit=20 - 500 null application/problem+json; charset=utf-8 3654.0261ms
|
||||
Binary file not shown.
@@ -908,6 +908,8 @@ def validate_account_snapshot_rows(rows: list[dict[str, Any]]) -> list[str]:
|
||||
errors.append(f"account_snapshot row {idx}: invalid account_type {account_type!r}")
|
||||
if not ticker and name != "예수금/D+2현금":
|
||||
errors.append(f"account_snapshot row {idx}: ticker required")
|
||||
if ticker and not re.fullmatch(r"(?:\d{6}|[A-Z0-9]{6}|[A-Z]{1,5})", ticker):
|
||||
errors.append(f"account_snapshot row {idx}: ticker must be 6 digits or an uppercase symbol")
|
||||
if not name:
|
||||
errors.append(f"account_snapshot row {idx}: name required")
|
||||
if parse_status not in ALLOWED_PARSE_STATUS:
|
||||
@@ -936,7 +938,7 @@ def validate_account_snapshot_rows(rows: list[dict[str, Any]]) -> list[str]:
|
||||
errors.append(f"account_snapshot row {idx}: CAPTURE_READ_OK rows require user_confirmed=Y")
|
||||
if entry_stage and canonical.get("entry_stage", {}).get("allowed") and entry_stage not in canonical["entry_stage"]["allowed"]:
|
||||
errors.append(f"account_snapshot row {idx}: invalid entry_stage {entry_stage!r}")
|
||||
if position_type and canonical.get("position_type", {}).get("allowed") and position_type not in canonical["position_type"]["allowed"]:
|
||||
if position_type and name != "예수금/D+2현금" and canonical.get("position_type", {}).get("allowed") and position_type not in canonical["position_type"]["allowed"]:
|
||||
errors.append(f"account_snapshot row {idx}: invalid position_type {position_type!r}")
|
||||
return errors
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"90c6053e24d905f92d61-fe6c7d0382f9666a596d"
|
||||
]
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: evidence\qe-m1-02-collection-run.spec.ts >> QE-M1-02: Collection Run List & Detail Verification >> QE-M1-02: Collection run renders in list with API-derived expected values
|
||||
- Location: tests\e2e\evidence\qe-m1-02-collection-run.spec.ts:24:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: expect(received).toBeTruthy()
|
||||
|
||||
Received: false
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- heading "An unhandled exception occurred while processing the request." [level=1] [ref=e2]
|
||||
- generic [ref=e3]: "PostgresException: 28P01: password authentication failed for user \"quantengine_app\""
|
||||
- paragraph [ref=e4]: Npgsql.Internal.NpgsqlConnector.ReadMessageLong(bool async, DataRowLoadingMode dataRowLoadingMode, bool readingNotifications, bool isReadingPrependedMessage)
|
||||
- list [ref=e5]:
|
||||
- listitem [ref=e6] [cursor=pointer]: Stack
|
||||
- listitem [ref=e7] [cursor=pointer]: Query
|
||||
- listitem [ref=e8] [cursor=pointer]: Cookies
|
||||
- listitem [ref=e9] [cursor=pointer]: Headers
|
||||
- listitem [ref=e10] [cursor=pointer]: Routing
|
||||
- list [ref=e12]:
|
||||
- listitem [ref=e13]:
|
||||
- 'heading "PostgresException: 28P01: password authentication failed for user \"quantengine_app\"" [level=2] [ref=e14]'
|
||||
- list [ref=e15]:
|
||||
- listitem [ref=e16]:
|
||||
- heading "Npgsql.Internal.NpgsqlConnector.ReadMessageLong(bool async, DataRowLoadingMode dataRowLoadingMode, bool readingNotifications, bool isReadingPrependedMessage)" [level=3] [ref=e17]
|
||||
- listitem [ref=e18]:
|
||||
- heading "System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder<TResult>+StateMachineBox<TStateMachine>.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(short token)" [level=3] [ref=e19]
|
||||
- listitem [ref=e20]:
|
||||
- heading "Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List<string> mechanisms, string username, bool async, CancellationToken cancellationToken)" [level=3] [ref=e21]
|
||||
- listitem [ref=e22]:
|
||||
- heading "Npgsql.Internal.NpgsqlConnector.Authenticate(string username, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e23]
|
||||
- listitem [ref=e24]:
|
||||
- heading "Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, string username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e25]
|
||||
- listitem [ref=e26]:
|
||||
- heading "Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, string username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e27]
|
||||
- listitem [ref=e28]:
|
||||
- heading "Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, string username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e29]
|
||||
- listitem [ref=e30]:
|
||||
- heading "Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e31]
|
||||
- listitem [ref=e32]:
|
||||
- heading "Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e33]
|
||||
- listitem [ref=e34]:
|
||||
- heading "System.Runtime.CompilerServices.ConfiguredValueTaskAwaitable<TResult>+ConfiguredValueTaskAwaiter.GetResult()" [level=3] [ref=e35]
|
||||
- listitem [ref=e36]:
|
||||
- heading "Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e37]
|
||||
- listitem [ref=e38]:
|
||||
- heading "System.Runtime.CompilerServices.ConfiguredValueTaskAwaitable<TResult>+ConfiguredValueTaskAwaiter.GetResult()" [level=3] [ref=e39]
|
||||
- listitem [ref=e40]:
|
||||
- heading "Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(bool async, CancellationToken cancellationToken)" [level=3] [ref=e41]
|
||||
- listitem [ref=e42]:
|
||||
- heading "Dapper.SqlMapper.QueryRowAsync<T>(IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in SqlMapper.Async.cs" [level=3] [ref=e43]:
|
||||
- text: Dapper.SqlMapper.QueryRowAsync<T>(IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in
|
||||
- code [ref=e44]: SqlMapper.Async.cs
|
||||
- listitem [ref=e45]:
|
||||
- heading "QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(string username) in WorkspaceRepository.cs" [level=3] [ref=e46]:
|
||||
- text: QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(string username) in
|
||||
- code [ref=e47]: WorkspaceRepository.cs
|
||||
- button "+" [ref=e48] [cursor=pointer]
|
||||
- list [ref=e50]:
|
||||
- listitem [ref=e51]: return await conn.QueryFirstOrDefaultAsync<WorkspaceAccount>(@"
|
||||
- listitem [ref=e52]:
|
||||
- heading "QuantEngine.Web.Services.AuthService.AuthenticateAsync(string username, string password, string ipAddress) in AuthService.cs" [level=3] [ref=e53]:
|
||||
- text: QuantEngine.Web.Services.AuthService.AuthenticateAsync(string username, string password, string ipAddress) in
|
||||
- code [ref=e54]: AuthService.cs
|
||||
- button "+" [ref=e55] [cursor=pointer]
|
||||
- list [ref=e57]:
|
||||
- listitem [ref=e58]: var account = await _workspaceRepository.GetAccountByUsernameAsync(username.Trim());
|
||||
- listitem [ref=e59]:
|
||||
- heading "QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(string username, string password, bool rememberUsername) in Login.cshtml.cs" [level=3] [ref=e60]:
|
||||
- text: QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(string username, string password, bool rememberUsername) in
|
||||
- code [ref=e61]: Login.cshtml.cs
|
||||
- button "+" [ref=e62] [cursor=pointer]
|
||||
- list [ref=e64]:
|
||||
- listitem [ref=e65]: var account = await _authService.AuthenticateAsync(username, password, ipAddress);
|
||||
- listitem [ref=e66]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory+GenericTaskHandlerMethod.Convert<T>(object taskAsObject)" [level=3] [ref=e67]
|
||||
- listitem [ref=e68]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory+GenericTaskHandlerMethod.Execute(object receiver, object[] arguments)" [level=3] [ref=e69]
|
||||
- listitem [ref=e70]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()" [level=3] [ref=e71]
|
||||
- listitem [ref=e72]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()" [level=3] [ref=e73]
|
||||
- listitem [ref=e74]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)" [level=3] [ref=e75]
|
||||
- listitem [ref=e76]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(ref State next, ref Scope scope, ref object state, ref bool isCompleted)" [level=3] [ref=e77]
|
||||
- listitem [ref=e78]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()" [level=3] [ref=e79]
|
||||
- listitem [ref=e80]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, object state, bool isCompleted)" [level=3] [ref=e81]
|
||||
- listitem [ref=e82]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)" [level=3] [ref=e83]
|
||||
- listitem [ref=e84]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(ref State next, ref Scope scope, ref object state, ref bool isCompleted)" [level=3] [ref=e85]
|
||||
- listitem [ref=e86]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, object state, bool isCompleted)" [level=3] [ref=e87]
|
||||
- listitem [ref=e88]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)" [level=3] [ref=e89]
|
||||
- listitem [ref=e90]:
|
||||
- heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)" [level=3] [ref=e91]
|
||||
- listitem [ref=e92]:
|
||||
- heading "Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)" [level=3] [ref=e93]
|
||||
- listitem [ref=e94]:
|
||||
- heading "Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)" [level=3] [ref=e95]
|
||||
- listitem [ref=e96]:
|
||||
- heading "Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)" [level=3] [ref=e97]
|
||||
- listitem [ref=e98]:
|
||||
- heading "Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)" [level=3] [ref=e99]
|
||||
- listitem [ref=e100]:
|
||||
- heading "Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)" [level=3] [ref=e101]
|
||||
- listitem [ref=e102]:
|
||||
- button "Show raw exception details" [ref=e104] [cursor=pointer]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 | import * as fs from 'fs';
|
||||
3 | import * as path from 'path';
|
||||
4 |
|
||||
5 | test.describe('QE-M1-02: Collection Run List & Detail Verification', () => {
|
||||
6 | // Login before each test
|
||||
7 | test.beforeEach(async ({ page }) => {
|
||||
8 | await page.goto('/Account/Login');
|
||||
9 | await page.waitForLoadState('domcontentloaded');
|
||||
10 |
|
||||
11 | // Fill login form with credentials (admin/admin)
|
||||
12 | const usernameInput = page.locator('#username');
|
||||
13 | const passwordInput = page.locator('#password');
|
||||
14 | const loginButton = page.locator('#loginBtn');
|
||||
15 |
|
||||
16 | await usernameInput.fill('admin');
|
||||
17 | await passwordInput.fill('admin');
|
||||
18 | await loginButton.click();
|
||||
19 |
|
||||
20 | // Wait for login to complete
|
||||
21 | await page.waitForLoadState('domcontentloaded');
|
||||
22 | });
|
||||
23 |
|
||||
24 | test('QE-M1-02: Collection run renders in list with API-derived expected values', async ({ page }) => {
|
||||
25 | // Step 1: Fetch expected values from API (source of truth)
|
||||
26 | const apiResponse = await page.request.get('/api/collection/runs?limit=20');
|
||||
> 27 | expect(apiResponse.ok()).toBeTruthy();
|
||||
| ^ Error: expect(received).toBeTruthy()
|
||||
28 |
|
||||
29 | const responseJson = await apiResponse.json();
|
||||
30 | const runs = (responseJson as any).runs || [];
|
||||
31 |
|
||||
32 | // Fail if no collection runs exist in database
|
||||
33 | if (runs.length === 0) {
|
||||
34 | throw new Error(
|
||||
35 | 'No collection runs in DB — run the daily-collection job first. ' +
|
||||
36 | 'Expected at least 1 run from kis_collection_runs table.'
|
||||
37 | );
|
||||
38 | }
|
||||
39 |
|
||||
40 | // Extract expected values from most recent run (first in list)
|
||||
41 | const expectedRun = runs[0];
|
||||
42 | const expectedRunId = expectedRun.runId;
|
||||
43 | const expectedTotalSnapshots = expectedRun.totalSnapshots ?? 0;
|
||||
44 | const expectedStatus = expectedRun.status; // e.g., "completed", "running", "failed"
|
||||
45 |
|
||||
46 | // Map status to Korean text (same logic as Index.cshtml — unknown statuses
|
||||
47 | // like COMPLETED_WITH_ERRORS render the raw status string in a secondary badge)
|
||||
48 | let expectedStatusText = String(expectedStatus ?? '');
|
||||
49 | if (expectedStatus?.toLowerCase() === 'completed') {
|
||||
50 | expectedStatusText = '완료';
|
||||
51 | } else if (expectedStatus?.toLowerCase() === 'running') {
|
||||
52 | expectedStatusText = '진행 중';
|
||||
53 | } else if (expectedStatus?.toLowerCase() === 'failed') {
|
||||
54 | expectedStatusText = '실패';
|
||||
55 | }
|
||||
56 |
|
||||
57 | console.log(
|
||||
58 | `\n=== QE-M1-02 Test Started ===\n` +
|
||||
59 | `Expected RunId: ${expectedRunId}\n` +
|
||||
60 | `Expected TotalSnapshots: ${expectedTotalSnapshots}\n` +
|
||||
61 | `Expected Status: ${expectedStatus} (rendered as: ${expectedStatusText})\n`
|
||||
62 | );
|
||||
63 |
|
||||
64 | // Step 2: Navigate to Collection admin page
|
||||
65 | await page.goto('/Admin/Collection');
|
||||
66 | await page.waitForLoadState('domcontentloaded');
|
||||
67 |
|
||||
68 | // Step 3: Verify page title contains "데이터 수집" (collection)
|
||||
69 | const pageTitle = await page.title();
|
||||
70 | expect(pageTitle).toContain('데이터 수집');
|
||||
71 |
|
||||
72 | // Step 4: Assert that a row containing the expected runId is visible
|
||||
73 | const runIdCell = page.locator(`td:has-text("${expectedRunId}")`);
|
||||
74 | await expect(runIdCell).toBeVisible();
|
||||
75 | console.log(`✓ RunId row found and visible: ${expectedRunId}`);
|
||||
76 |
|
||||
77 | // Step 5: Find the row containing this runId and verify the snapshot count
|
||||
78 | const tableRow = runIdCell.locator('xpath=ancestor::tr');
|
||||
79 |
|
||||
80 | // Within the row, find all td elements and map to columns
|
||||
81 | // Columns: 실행 ID (0), 시작 시간 (1), 종료 시간 (2), 상태 (3), 스냅샷 수 (4), 오류 수 (5)
|
||||
82 | const cells = tableRow.locator('td');
|
||||
83 | const cellCount = await cells.count();
|
||||
84 | expect(cellCount).toBeGreaterThanOrEqual(5); // At least 5 columns
|
||||
85 |
|
||||
86 | // Cell 4 (index 4) is "스냅샷 수" (total snapshots)
|
||||
87 | const snapshotCell = cells.nth(4);
|
||||
88 | const snapshotText = await snapshotCell.textContent();
|
||||
89 | expect(snapshotText?.trim()).toBe(String(expectedTotalSnapshots));
|
||||
90 | console.log(`✓ Snapshot count matches: ${snapshotText?.trim()} == ${expectedTotalSnapshots}`);
|
||||
91 |
|
||||
92 | // Cell 3 (index 3) is "상태" (status badge)
|
||||
93 | const statusCell = cells.nth(3);
|
||||
94 | const statusBadge = statusCell.locator('span.badge');
|
||||
95 | const statusBadgeText = await statusBadge.textContent();
|
||||
96 | expect(statusBadgeText?.trim()).toBe(expectedStatusText);
|
||||
97 | console.log(`✓ Status badge matches: ${statusBadgeText?.trim()} == ${expectedStatusText}`);
|
||||
98 |
|
||||
99 | // Step 6: Create screenshot directory and take screenshot of collection list
|
||||
100 | const screenshotDir = path.join(process.cwd(), 'Temp', 'evidence', 'QE-M1-02', 'screenshots');
|
||||
101 | fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
102 |
|
||||
103 | await page.screenshot({
|
||||
104 | path: path.join(screenshotDir, '01-collection-page.png'),
|
||||
105 | fullPage: true,
|
||||
106 | });
|
||||
107 | console.log(`✓ Screenshot saved: 01-collection-page.png`);
|
||||
108 |
|
||||
109 | // Step 7: Navigate to the run detail page
|
||||
110 | // The detail page route is /Admin/Collection/{runId}
|
||||
111 | await page.goto(`/Admin/Collection/${expectedRunId}`);
|
||||
112 | await page.waitForLoadState('domcontentloaded');
|
||||
113 |
|
||||
114 | // Step 8: Verify detail page title contains the runId
|
||||
115 | const detailPageTitle = await page.title();
|
||||
116 | expect(detailPageTitle).toContain('수집 실행 상세');
|
||||
117 |
|
||||
118 | // Step 9: Verify that the RunId is displayed on the detail page
|
||||
119 | // The page title shows: "수집 실행 상세 - {runId}"
|
||||
120 | const pageHeading = page.locator('h2.page-title');
|
||||
121 | const headingText = await pageHeading.textContent();
|
||||
122 | expect(headingText).toContain(expectedRunId);
|
||||
123 | console.log(`✓ Detail page title contains RunId: ${headingText}`);
|
||||
124 |
|
||||
125 | // Step 10: Verify snapshots count is displayed on detail page
|
||||
126 | // The snapshot count appears in a card with "스냅샷 수" as the title
|
||||
127 | const snapshotCountCard = page.locator('h4.card-title:has-text("스냅샷 수")');
|
||||
```
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
BIN
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user